Compare commits
336 Commits
0.16.3
...
3a02bcad9d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a02bcad9d | |||
| 6a2cb4a881 | |||
| 10af7190ab | |||
| 62c81c65ef | |||
| 29a677142c | |||
| 29cd2f8015 | |||
| 5e835595c3 | |||
| b75b259bd9 | |||
| c1ef057ad2 | |||
| 8bfc5646f9 | |||
| 851c747a60 | |||
| 49b8b86249 | |||
| e8f120f85e | |||
| 34a48c8c7b | |||
| 4a18c01cdb | |||
| f4de4dbcb7 | |||
| 64ec3c1cca | |||
| 7a74f7f882 | |||
| 373db5563a | |||
| 349b10904a | |||
| 8e8d25aa1d | |||
| 28db747b59 | |||
| a0b867e20a | |||
| 6d25e5f663 | |||
| 2e7d6364a2 | |||
| a8dc336950 | |||
| 653aabfbb1 | |||
| 48e13af2e8 | |||
| f1f615f0ed | |||
| 052d75ea26 | |||
| 223de52a12 | |||
| a5ff6abf56 | |||
| 74252b8759 | |||
| 466f33dd3c | |||
| c9abb22bfb | |||
| 18d21e6651 | |||
| 1f67d4c0a6 | |||
| ff3fe00afa | |||
| 2e289f3852 | |||
| c0659f26a5 | |||
| 8ac6ed542f | |||
| 14e32dae12 | |||
| 31d3a85e8c | |||
| 7cf817025b | |||
| f5675e5319 | |||
| 40617b77e2 | |||
| 64967aa357 | |||
| 88166d01eb | |||
| f506fa465f | |||
| 5934dcdf8e | |||
| 1e11679419 | |||
| 0e5d8e6b2f | |||
| 118e208a36 | |||
| 93666cec54 | |||
| 6d5ebb68e3 | |||
| c89b434d18 | |||
| e83f922e32 | |||
| 508e2db60e | |||
| e9f9004d6d | |||
| e3364d15ce | |||
| 744e9f1d38 | |||
| a343e2f3fa | |||
| 4036e883fd | |||
| ff6de28b24 | |||
| ad03f22b20 | |||
| 4f8cf4f244 | |||
| d6efdb6979 | |||
| d38e960897 | |||
| 88a8b8320c | |||
| 5d9834b63d | |||
| 2dc7acc536 | |||
| 3a79c17006 | |||
| c43771c757 | |||
| a5eff556be | |||
| 5b8559efd0 | |||
| e3fb529419 | |||
| 8357ce8901 | |||
| 8309a4e3c3 | |||
| 7d8e1ac817 | |||
| 446144bc51 | |||
| d36502ec09 | |||
| 770a51b9c0 | |||
| 210ff0a4aa | |||
| db52ebeea7 | |||
| 0e7286ac4e | |||
| 4ee687b9c7 | |||
| 4b8785ead7 | |||
| bec7ef337e | |||
| e7bc38d0f8 | |||
| 1be8e4b083 | |||
| 88a94aadca | |||
| 2d8f433314 | |||
| d1844c01a0 | |||
| 9848e5874d | |||
| a027e877f7 | |||
| 82a7fd8673 | |||
| c897507de4 | |||
| 009ed2ace3 | |||
| d945acfb92 | |||
| 8fd0ed6e17 | |||
| a5d72ce4c3 | |||
| 809014736c | |||
| fcccd6d9a4 | |||
| a5295d7973 | |||
| 186ae18e1f | |||
| 6f7f739ca6 | |||
| c2ba8a48ac | |||
| 1530de3188 | |||
| 2d235c0577 | |||
| b0eb58953b | |||
| 7309181fed | |||
| 971fee5b4b | |||
| 920a9180c8 | |||
| d568a377f0 | |||
| 3851624dd7 | |||
| 8c865fe008 | |||
| 572dbf7a88 | |||
| 7addd50577 | |||
| cd5dc25642 | |||
| 9c2355978e | |||
| 4b9b785e50 | |||
| 050b2b9d77 | |||
| d12cca304f | |||
| 8603bbd5cb | |||
| 749e74a54c | |||
| 7b3692ef7b | |||
| c49f6a1740 | |||
| 1d813e4643 | |||
| 5e0a429d81 | |||
| d928d266b9 | |||
| b4dbbb4211 | |||
| dcb5260cfc | |||
| a8747dfe77 | |||
| a474b5df48 | |||
| 082979bea6 | |||
| 1275186d86 | |||
| cd60ac6387 | |||
| bdfbd3e5c0 | |||
| dff63f325f | |||
| 2b634e3b7e | |||
| 723d739405 | |||
| e62a07af37 | |||
| f86c3b2935 | |||
| 050add8543 | |||
| 8faf0296a6 | |||
| f209f3b107 | |||
| b233b60ae0 | |||
| e1d4a7c5a4 | |||
| 59e8339e94 | |||
| 9277db97e5 | |||
| e755dc6641 | |||
| 782f5c15d6 | |||
| 2f4fae7d02 | |||
| 4b7c5aa58d | |||
| d4f82f2d6f | |||
| 106d25c20f | |||
| d77caa2783 | |||
| b5bfad73ef | |||
| 274b2704ed | |||
| 80fcb6c002 | |||
| c6f3c90006 | |||
| 387dee7d37 | |||
| 188e899357 | |||
| 30b005fa46 | |||
| 72f739ee5a | |||
| 56ee14512d | |||
| 8c947d35dd | |||
| 61bab1f734 | |||
| 42ce6df9bd | |||
| cbd46df4bc | |||
| e7203cdb9b | |||
| 7246adfeb6 | |||
| a5606951c5 | |||
| 0b4537b7ed | |||
| 6306390f82 | |||
| 350d3ceb14 | |||
| a1ff82bfec | |||
| 92c0c668b3 | |||
| 3b77feda45 | |||
| 45c402f8c1 | |||
| 90a1398438 | |||
| c7a81802ac | |||
| a9a8678ac0 | |||
| cbf0583871 | |||
| 5cac1fe109 | |||
| 6782ed312d | |||
| fda505ea4e | |||
| 8db111f66f | |||
| ee1cae496a | |||
| 9403c68184 | |||
| 96030f4a99 | |||
| a8c3925af4 | |||
| a2f507a976 | |||
| 7a7edc6e47 | |||
| af6c39fb85 | |||
| 36cfdd6f6c | |||
| b11d87af75 | |||
| 1cf50209a4 | |||
| 8a5486fb2c | |||
| 135d6e65fa | |||
| 965f2dd41b | |||
| 1a1de02843 | |||
| a1868e7b2c | |||
| 52494651bf | |||
| 1093aa2376 | |||
| d1f04c15a9 | |||
| fd3487c225 | |||
| df91526b0c | |||
| 70f103db6f | |||
| b0b32821e3 | |||
| 278cab32ea | |||
| 06e075553a | |||
| 833368c8d7 | |||
| f70bab30d0 | |||
| f230af89eb | |||
| bbc27209ab | |||
| b7638c648a | |||
| c8926cf887 | |||
| b8dd3ee258 | |||
| dc965687c2 | |||
| ebc66bbf64 | |||
| d04db0ecb5 | |||
| fc72b23b11 | |||
| a681b4d63b | |||
| c452ac24e0 | |||
| ae889bff7d | |||
| 99dc86dc27 | |||
| 8eefcb8290 | |||
| ad0f9a54d0 | |||
| 1531b77b5c | |||
| 9437fdba60 | |||
| a7551ef162 | |||
| c20204a6ea | |||
| 685de842ea | |||
| 7d13967708 | |||
| 109697a746 | |||
| dde28f4aff | |||
| 2f6ed3770f | |||
| e3d1cfb838 | |||
| 1821ac0d7b | |||
| 4eb8289e55 | |||
| 66e805542c | |||
| f91b127a2c | |||
| b2077678e2 | |||
| 5427198185 | |||
| 2bdba14cd6 | |||
| 95d8c4e4d6 | |||
| 6ab7745151 | |||
| 8b062a6c1d | |||
| cd48e7a402 | |||
| 22830b0cea | |||
| fd36034f6d | |||
| edf9fbd9c1 | |||
| e8e989bb63 | |||
| 69401d11c8 | |||
| 759caef45d | |||
| 9514861b32 | |||
| aa644aa9cf | |||
| 94820b1d9c | |||
| 4db8793d5c | |||
| 7c6e895ae4 | |||
| b1b67528bf | |||
| dd54a33159 | |||
| 92c4f91e5a | |||
| 838b19e996 | |||
| 3808277025 | |||
| f64863f2bc | |||
| 2c199c0e93 | |||
| 4924ef316f | |||
| 64cb17e91f | |||
| 1fd325823b | |||
| 1590ce5f18 | |||
| 3548c29f97 | |||
| 0fa831fa42 | |||
| a2f64a98c3 | |||
| 872ca17432 | |||
| 224c165d72 | |||
| bf7d2514f2 | |||
| 4e37bc5ab9 | |||
| 125da84f4e | |||
| 36ceb4c7fe | |||
| 88a3831975 | |||
| 63361964ca | |||
| 40b54b27f4 | |||
| a7eca4b9a7 | |||
| d152412e99 | |||
| 3ba6c6b6e4 | |||
| bffbf47c2f | |||
| f4e81da533 | |||
| 4b7f5459be | |||
| c68b0e9d7e | |||
| 32ec65116b | |||
| da8d26fcd9 | |||
| d33954e494 | |||
| 1b306d6493 | |||
| c881143e1b | |||
| 141700fcb3 | |||
| 7357b5bfec | |||
| 99cabd0007 | |||
| cf77e12cc3 | |||
| 3f2cbbb34a | |||
| 650ecf12c6 | |||
| d7cc009d07 | |||
| a872cf3611 | |||
| 1f9713312b | |||
| 159e555d7c | |||
| 981f4f9c9a | |||
| ddd5ce1392 | |||
| 7a75b31b56 | |||
| 24ac545f55 | |||
| d5da8ae701 | |||
| 53a04d064d | |||
| c0871a3b9e | |||
| d917dd8b2c | |||
| 6fc8084f2d | |||
| 41d6fe8ff6 | |||
| 73838312cd | |||
| eb2dd4c839 | |||
| 1a0c4f69f0 | |||
| 356e579558 | |||
| e980e3c5c9 | |||
| 8773542099 | |||
| 70378c9968 | |||
| c871087496 | |||
| 059d7780a0 | |||
| db36329011 | |||
| 69aa80e6c1 | |||
| 99a6e5107b | |||
| 39e2fdce27 | |||
| b2f98d780b | |||
| 07b5dc6a2c | |||
| 7207ca385e | |||
| 2a254e28d0 | |||
| d4377e49ac | |||
| 9dc0a818ff | |||
| 5e672fc9ed |
24
.drone.yml
24
.drone.yml
@ -34,13 +34,13 @@ steps:
|
||||
command_timeout: 2m
|
||||
script:
|
||||
- pip uninstall -y vrobbler
|
||||
- pip install git+https://code.unbl.ink/secstate/vrobbler.git@main
|
||||
- pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
|
||||
- vrobbler migrate
|
||||
- vrobbler collectstatic --noinput
|
||||
- immortalctl restart celery && immortalctl restart vrobbler
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
ref:
|
||||
- refs/tags/*
|
||||
- name: build success notification
|
||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||
when:
|
||||
@ -50,8 +50,15 @@ steps:
|
||||
topic: drone
|
||||
priority: low
|
||||
tags:
|
||||
- failure
|
||||
- success
|
||||
- vrobbler
|
||||
actions:
|
||||
- action: view
|
||||
label: Changes
|
||||
url: "{{ .Commit.Link }}"
|
||||
- action: view
|
||||
label: Build
|
||||
url: "{{ .Build.Link }}"
|
||||
- name: build failure notification
|
||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||
when:
|
||||
@ -61,8 +68,15 @@ steps:
|
||||
topic: drone
|
||||
priority: high
|
||||
tags:
|
||||
- success
|
||||
- failure
|
||||
- vrobbler
|
||||
actions:
|
||||
- action: view
|
||||
label: Changes
|
||||
url: "{{ .Commit.Link }}"
|
||||
- action: view
|
||||
label: Build
|
||||
url: "{{ .Build.Link }}"
|
||||
volumes:
|
||||
- name: docker
|
||||
host:
|
||||
|
||||
114
.gitea/workflows/build-deploy.yml
Normal file
114
.gitea/workflows/build-deploy.yml
Normal file
@ -0,0 +1,114 @@
|
||||
name: build & deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["*"]
|
||||
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"
|
||||
|
||||
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
|
||||
- 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
|
||||
|
||||
# Notifications (success/failure) for the test job
|
||||
- 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
|
||||
|
||||
deploy:
|
||||
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
|
||||
# if: startsWith(gitea.ref, 'refs/tags/')
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
command_timeout: 2m
|
||||
script: |
|
||||
pip uninstall -y vrobbler
|
||||
pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
|
||||
vrobbler migrate
|
||||
vrobbler collectstatic --noinput
|
||||
immortalctl restart celery && immortalctl restart vrobbler
|
||||
|
||||
- name: Notify deploy success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler deploy success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler,deploy" \
|
||||
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "🚀 Deploy succeeded: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify deploy failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler deploy failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler,deploy" \
|
||||
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "💥 Deploy failed: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,3 +3,5 @@ vrobbler.conf
|
||||
media/
|
||||
dist/
|
||||
.coverage
|
||||
tmp/*
|
||||
vrobbler/static/*
|
||||
|
||||
2
Makefile
2
Makefile
@ -1,5 +1,5 @@
|
||||
deploy:
|
||||
ssh vrobbler.service "rm -rf /usr/local/lib/python3.11/site-packages/vrobbler-0.15.4.dist-info/ && pip install git+https://code.unbl.ink/secstate/vrobbler.git@develop && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
|
||||
ssh vrobbler.service "pip uninstall vrobbler && pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
|
||||
logs:
|
||||
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
|
||||
test:
|
||||
|
||||
1244
PROJECT.org
Normal file
1244
PROJECT.org
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
10
justfile
Normal file
10
justfile
Normal file
@ -0,0 +1,10 @@
|
||||
dj-port := "0.0.0.0:" + env_var_or_default("DJANGO_PORT", "8000")
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
django:
|
||||
poetry run python manage.py runserver {{dj-port}}
|
||||
|
||||
shell:
|
||||
poetry run python manage.py shell
|
||||
5706
poetry.lock
generated
5706
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9,<3.12"
|
||||
python = ">=3.11,<3.14"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
@ -16,8 +16,8 @@ httpx = "<=0.27.2"
|
||||
djangorestframework = "^3.13.1"
|
||||
Markdown = "^3.3.6"
|
||||
django-filter = "^21.1"
|
||||
Pillow = "^9.0.1"
|
||||
psycopg2 = "^2.9.3"
|
||||
Pillow = "^10.0.0"
|
||||
psycopg2 = "2.9.10"
|
||||
dj-database-url = "^0.5.0"
|
||||
django-mathfilters = "^1.0.0"
|
||||
django-allauth = "^0.50.0"
|
||||
@ -28,7 +28,7 @@ django-markdownify = "^0.9.1"
|
||||
gunicorn = "^20.1.0"
|
||||
django-simple-history = "^3.1.1"
|
||||
musicbrainzngs = "^0.7.1"
|
||||
cinemagoer = "^2022.12.27"
|
||||
cinemagoerng = {git = "https://github.com/cinemagoer/cinemagoerng"}
|
||||
pysportsdb = "^0.1.0"
|
||||
pytz = "^2022.7.1"
|
||||
django-redis = "^5.2.0"
|
||||
@ -41,11 +41,11 @@ beautifulsoup4 = "^4.11.2"
|
||||
django-storages = "^1.13.2"
|
||||
stream-sqlite = "^0.0.41"
|
||||
ipython = "^8.14.0"
|
||||
pendulum = "^2.1.2"
|
||||
pendulum = "^3"
|
||||
trafilatura = "^1.6.3"
|
||||
django-imagekit = "^5.0.0"
|
||||
thefuzz = "^0.22.1"
|
||||
dataclass-wizard = "0.22.0"
|
||||
dataclass-wizard = "^0.35.0"
|
||||
webdavclient3 = "^3.14.6"
|
||||
boto3 = "^1.35.37"
|
||||
urllib3 = "<2"
|
||||
@ -53,6 +53,13 @@ django-oauth-toolkit = "^3.0.1"
|
||||
meta-yt = "^0.1.9"
|
||||
berserk = "^0.13.2"
|
||||
poetry-bumpversion = "^0.3.3"
|
||||
orgparse = "^0.4.20250520"
|
||||
tmdbv3api = "^1.9.0"
|
||||
themoviedb = "^1.0.2"
|
||||
feedparser = "^6.0.12"
|
||||
titlecase = "^2.4.1"
|
||||
bgg-api = "^1.1.13"
|
||||
recipe-scrapers = "^15.11.0"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
@ -76,8 +83,7 @@ types-requests = "^2.27"
|
||||
bandit = "^1.7.4"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
addopts = "-ra -q --reuse-db"
|
||||
addopts = "-ra -q --reuse-db --no-migrations"
|
||||
testpaths = ["tests"]
|
||||
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
||||
|
||||
|
||||
27
scripts/README.org
Normal file
27
scripts/README.org
Normal file
@ -0,0 +1,27 @@
|
||||
#+title: Readme
|
||||
|
||||
Scripts are a collection of helpful utility scripts, or simple gut-check tests for various functional pieces.
|
||||
|
||||
* test_recipe_scraper.py
|
||||
Asserts various urls by making actual calls out to the internet, while our test suite mocks return values.
|
||||
|
||||
#+begin_src shell
|
||||
python ../manage.py shell < ../scripts/test_recipe_scraper.py
|
||||
#+end_src
|
||||
|
||||
#+RESULTS:
|
||||
| Eagerly | running | all | tasks |
|
||||
| Connected | to | sqlite@db.sqlite3 | |
|
||||
| Checking: | https://cookingwithmike.com/quinoa-meatloaf/ | | |
|
||||
| Checking: | https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe | | |
|
||||
| Checking: | https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads | | |
|
||||
|
||||
* test_koreader_import.py
|
||||
Run through an actual koreader sqlite file and make sure imports work as expected
|
||||
|
||||
#+begin_src shell
|
||||
rm db.sqlite3
|
||||
cp ../db.sqlite3 .
|
||||
python ../manage.py shell < ../scripts/test_koreader_import.py
|
||||
#+end_src
|
||||
|
||||
BIN
scripts/koreader-test.sqlite3
Normal file
BIN
scripts/koreader-test.sqlite3
Normal file
Binary file not shown.
6
scripts/test_koreader_import.py
Normal file
6
scripts/test_koreader_import.py
Normal file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from books.koreader import process_koreader_sqlite_file
|
||||
|
||||
|
||||
process_koreader_sqlite_file("./koreader-test.sqlite3", 1)
|
||||
21
scripts/test_recipe_scraper.py
Normal file
21
scripts/test_recipe_scraper.py
Normal file
@ -0,0 +1,21 @@
|
||||
import requests
|
||||
from foods.sources.rscraper import (
|
||||
RecipeScraperService,
|
||||
)
|
||||
|
||||
|
||||
test_urls = {
|
||||
"https://cookingwithmike.com/quinoa-meatloaf/": True,
|
||||
"https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe": True,
|
||||
"https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads": False,
|
||||
"https://tastesbetterfromscratch.com/belgian-waffles/": True,
|
||||
}
|
||||
|
||||
for k, v in test_urls.items():
|
||||
|
||||
html = requests.get(k).text
|
||||
print("Checking: ", k)
|
||||
if v:
|
||||
assert RecipeScraperService().is_recipe(html, k)
|
||||
else:
|
||||
assert not RecipeScraperService().is_recipe(html, k)
|
||||
@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
from boardgames.bgg import (
|
||||
take_first,
|
||||
lookup_boardgame_id_from_bgg,
|
||||
@ -5,12 +6,14 @@ from boardgames.bgg import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_take_first():
|
||||
assert take_first([]) == ""
|
||||
|
||||
assert take_first(["a", "b"]) == "a"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_lookup_boardgame_id_from_bgg():
|
||||
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
||||
assert bgg_id == "15"
|
||||
@ -19,6 +22,7 @@ def test_lookup_boardgame_id_from_bgg():
|
||||
assert bgg_id == None
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_lookup_boardgame_from_bgg():
|
||||
bgg_result = lookup_boardgame_from_bgg(15)
|
||||
assert bgg_result.get("bggeek_id") == 15
|
||||
|
||||
0
tests/foods_tests/__init__.py
Normal file
0
tests/foods_tests/__init__.py
Normal file
198
tests/foods_tests/test_recipe_scraper.py
Normal file
198
tests/foods_tests/test_recipe_scraper.py
Normal file
@ -0,0 +1,198 @@
|
||||
import pytest
|
||||
from foods.sources.rscraper import (
|
||||
RecipeScraperService,
|
||||
)
|
||||
|
||||
|
||||
RECIPE_HTML_WITH_SCHEMA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Test Recipe",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Test Author"
|
||||
},
|
||||
"recipeIngredient": ["1 cup flour", "2 eggs", "1/2 cup sugar"],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Mix ingredients together"
|
||||
}
|
||||
],
|
||||
"totalTime": "PT30M",
|
||||
"recipeYield": "4 servings"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Recipe</h1>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
RECIPE_HTML_WITHOUT_SCHEMA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Not a Recipe Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to My Blog</h1>
|
||||
<p>This is just a regular blog post about cooking.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
RECIPE_HTML_WITH_MICRODATA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Recipe</title>
|
||||
</head>
|
||||
<body itemscope itemtype="http://schema.org/Recipe">
|
||||
<h1 itemprop="name">Microdata Recipe</h1>
|
||||
<div itemprop="author" itemscope itemtype="http://schema.org/Person">
|
||||
<span itemprop="name">Test Author</span>
|
||||
</div>
|
||||
<div itemprop="recipeIngredient">1 cup flour</div>
|
||||
<div itemprop="recipeIngredient">2 eggs</div>
|
||||
<div itemprop="recipeInstructions">
|
||||
<div itemprop="text">Mix all ingredients</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class TestRecipeScraperService:
|
||||
@pytest.fixture
|
||||
def scraper(self):
|
||||
return RecipeScraperService()
|
||||
|
||||
def test_is_recipe_with_valid_schema(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_is_recipe_without_schema(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITHOUT_SCHEMA, "https://example.com/blog"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_is_recipe_with_microdata(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITH_MICRODATA, "https://example.com/recipe"
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_scrape_returns_title(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert result["title"] == "Test Recipe"
|
||||
|
||||
def test_scrape_returns_ingredients(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert len(result["ingredients"]) == 3
|
||||
assert "1 cup flour" in result["ingredients"]
|
||||
|
||||
def test_scrape_returns_instructions(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert len(result["instructions"]) > 0
|
||||
assert "Mix ingredients together" in result["instructions"]
|
||||
|
||||
def test_scrape_returns_yields(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert result["yields"] == "4 servings"
|
||||
|
||||
def test_scrape_returns_total_time(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert result["total_time"] == 30
|
||||
|
||||
def test_scrape_returns_url(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert result["url"] == "https://example.com/recipe"
|
||||
|
||||
def test_scrape_raises_on_invalid_html(self, scraper):
|
||||
with pytest.raises(ValueError):
|
||||
scraper.scrape("", "https://example.com/recipe")
|
||||
|
||||
def test_scrape_handles_missing_optional_fields(self, scraper):
|
||||
minimal_html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Minimal Recipe"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
"""
|
||||
result = scraper.scrape(minimal_html, "https://example.com/minimal")
|
||||
assert result["title"] == "Minimal Recipe"
|
||||
assert result["ingredients"] == []
|
||||
assert result["instructions"] == []
|
||||
|
||||
def test_parse_servings(self, scraper):
|
||||
assert scraper.parse_servings("4 servings") == 4
|
||||
assert scraper.parse_servings("6 people") == 6
|
||||
assert scraper.parse_servings("2") == 2
|
||||
assert scraper.parse_servings("serves 8") == 8
|
||||
assert scraper.parse_servings(None) is None
|
||||
assert scraper.parse_servings("") is None
|
||||
|
||||
def test_extract_tags_from_cuisine(self, scraper):
|
||||
recipe_data = {"cuisine": "Italian"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Italian" in tags
|
||||
|
||||
def test_extract_tags_from_cuisine_list(self, scraper):
|
||||
recipe_data = {"cuisine": ["Italian", "Mexican"]}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Italian" in tags
|
||||
assert "Mexican" in tags
|
||||
|
||||
def test_extract_tags_from_dietary(self, scraper):
|
||||
recipe_data = {"dietary": "Gluten-Free"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Gluten-Free" in tags
|
||||
|
||||
def test_extract_tags_from_course(self, scraper):
|
||||
recipe_data = {"course": "Dessert"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Dessert" in tags
|
||||
|
||||
def test_extract_tags_from_keywords(self, scraper):
|
||||
recipe_data = {"keywords": "easy, quick, healthy"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "easy" in tags
|
||||
assert "quick" in tags
|
||||
assert "healthy" in tags
|
||||
|
||||
def test_extract_tags_from_keywords_list(self, scraper):
|
||||
recipe_data = {"keywords": ["comfort food", "winter"]}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "comfort food" in tags
|
||||
assert "winter" in tags
|
||||
135
tests/foods_tests/test_usda.py
Normal file
135
tests/foods_tests/test_usda.py
Normal file
@ -0,0 +1,135 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from foods.sources.usda import (
|
||||
USDAFoodAPI,
|
||||
NutritionCalculator,
|
||||
)
|
||||
|
||||
|
||||
class TestUSDAFoodAPI:
|
||||
@pytest.fixture
|
||||
def usda_api(self):
|
||||
with patch(
|
||||
"vrobbler.apps.foods.sources.usda.settings"
|
||||
) as mock_settings:
|
||||
mock_settings.USDA_API_KEY = "test_api_key"
|
||||
return USDAFoodAPI(api_key="test_api_key")
|
||||
|
||||
def test_extract_nutrients_with_nutrient_number(self, usda_api):
|
||||
food_data = {
|
||||
"description": "Test Food",
|
||||
"foodNutrients": [
|
||||
{
|
||||
"nutrientNumber": "203",
|
||||
"nutrientName": "Protein",
|
||||
"value": 10.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "204",
|
||||
"nutrientName": "Total lipid (fat)",
|
||||
"value": 5.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "205",
|
||||
"nutrientName": "Carbohydrate, by difference",
|
||||
"value": 20.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "208",
|
||||
"nutrientName": "Energy",
|
||||
"value": 150.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "269",
|
||||
"nutrientName": "Sugars, total",
|
||||
"value": 5.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 10.0
|
||||
assert result["fat"] == 5.0
|
||||
assert result["carbohydrates"] == 20.0
|
||||
assert result["calories"] == 150.0
|
||||
assert result["sugar"] == 5.0
|
||||
|
||||
def test_extract_nutrients_with_nested_nutrient(self, usda_api):
|
||||
food_data = {
|
||||
"description": "Test Food",
|
||||
"foodNutrients": [
|
||||
{
|
||||
"nutrient": {"id": 203, "name": "Protein"},
|
||||
"value": 10.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 10.0
|
||||
|
||||
def test_extract_nutrients_with_empty_nutrients(self, usda_api):
|
||||
food_data = {"description": "Test Food", "foodNutrients": []}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 0
|
||||
assert result["calories"] == 0
|
||||
|
||||
def test_extract_nutrients_with_no_nutrients_key(self, usda_api):
|
||||
food_data = {"description": "Test Food"}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 0
|
||||
|
||||
|
||||
class TestNutritionCalculator:
|
||||
@pytest.fixture
|
||||
def calculator(self):
|
||||
with patch("vrobbler.apps.foods.sources.usda.USDAFoodAPI"):
|
||||
return NutritionCalculator()
|
||||
|
||||
def test_parse_ingredient_with_fraction(self, calculator):
|
||||
result = calculator.parse_ingredient("1/2 cup flour")
|
||||
assert result["quantity"] == 0.5
|
||||
assert result["unit"] == "cup"
|
||||
assert result["ingredient"] == "flour"
|
||||
|
||||
def test_parse_ingredient_with_mixed_number(self, calculator):
|
||||
result = calculator.parse_ingredient("1 1/2 cups sugar")
|
||||
assert result["quantity"] == 1.5
|
||||
assert result["unit"] == "cups"
|
||||
assert result["ingredient"] == "sugar"
|
||||
|
||||
def test_parse_ingredient_with_decimal(self, calculator):
|
||||
result = calculator.parse_ingredient("0.5 tsp salt")
|
||||
assert result["quantity"] == 0.5
|
||||
assert result["unit"] == "tsp"
|
||||
assert result["ingredient"] == "salt"
|
||||
|
||||
def test_parse_ingredient_with_whole_number(self, calculator):
|
||||
result = calculator.parse_ingredient("3 eggs")
|
||||
assert result["quantity"] == 3
|
||||
assert result["unit"] is None
|
||||
assert result["ingredient"] == "eggs"
|
||||
|
||||
def test_parse_ingredient_with_no_quantity(self, calculator):
|
||||
result = calculator.parse_ingredient("salt to taste")
|
||||
assert result["quantity"] == 1
|
||||
|
||||
def test_clean_ingredient_name_removes_modifiers(self, calculator):
|
||||
result = calculator._clean_ingredient_name("fresh chopped onions")
|
||||
assert "fresh" not in result.lower()
|
||||
assert "chopped" not in result.lower()
|
||||
|
||||
def test_clean_ingredient_name_removes_parentheses(self, calculator):
|
||||
result = calculator._clean_ingredient_name("flour (sifted)")
|
||||
assert "(" not in result
|
||||
assert ")" not in result
|
||||
|
||||
def test_convert_to_grams_cup(self, calculator):
|
||||
result = calculator._convert_to_grams(2, "cups", "flour")
|
||||
assert result == 480
|
||||
|
||||
def test_convert_to_grams_tablespoon(self, calculator):
|
||||
result = calculator._convert_to_grams(3, "tbsp", "olive oil")
|
||||
assert result == 45
|
||||
|
||||
def test_convert_to_grams_unknown_unit(self, calculator):
|
||||
result = calculator._convert_to_grams(1, "unknown", "something")
|
||||
assert result == 100
|
||||
@ -7,23 +7,34 @@ from rest_framework.authtoken.models import Token
|
||||
from boardgames.models import BoardGame
|
||||
from music.models import Track, Artist
|
||||
from scrobbles.models import Scrobble
|
||||
from people.models import Person
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boardgame_scrobble():
|
||||
user = User.objects.create(
|
||||
email="test@exmaple.com", first_name="Test", last_name="User"
|
||||
)
|
||||
first = Person.objects.create(name="First Player")
|
||||
second = Person.objects.create(name="Second Player")
|
||||
return Scrobble.objects.create(
|
||||
board_game=BoardGame.objects.create(title="Test Board Game"),
|
||||
media_type="BoardGame",
|
||||
played_to_completion=True,
|
||||
log={
|
||||
"players": [
|
||||
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"}
|
||||
]
|
||||
{
|
||||
"person_id": first.id,
|
||||
"win": True,
|
||||
"score": 30,
|
||||
"color": "Blue",
|
||||
},
|
||||
{
|
||||
"person_id": second.id,
|
||||
"win": False,
|
||||
"score": 28,
|
||||
"color": "Red",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@ -33,7 +44,7 @@ def test_track():
|
||||
Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=Artist.objects.create(name="Carly Rae Jepsen"),
|
||||
run_time_seconds=60,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
@ -115,6 +126,14 @@ def mopidy_podcast_request_data():
|
||||
).request_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_podcast_https_request_data():
|
||||
mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6"
|
||||
return MopidyRequest(
|
||||
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||
).request_json
|
||||
|
||||
|
||||
class JellyfinTrackRequest:
|
||||
name = "Emotion"
|
||||
artist = "Carly Rae Jepsen"
|
||||
@ -129,6 +148,7 @@ class JellyfinTrackRequest:
|
||||
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
||||
status = "resumed"
|
||||
client_name = "Jellyfin"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.request_data = {
|
||||
@ -152,6 +172,7 @@ class JellyfinTrackRequest:
|
||||
"musicbrainz_artist_id", self.musicbrainz_artist_id
|
||||
),
|
||||
"Status": kwargs.get("status", self.status),
|
||||
"ClientName": kwargs.get("client_name", self.client_name),
|
||||
}
|
||||
|
||||
def __eq__(self, other):
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
@ -26,8 +27,21 @@ def build_scrobbles(client, request_json, num=7, spacing=2):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.get_album_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_track_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
|
||||
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||
@patch("music.models.lookup_album_from_tadb", return_value={})
|
||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||
def test_scrobble_counts_data(client, mopidy_track):
|
||||
def test_scrobble_counts_data(
|
||||
mock_lookup_album_tadb,
|
||||
mock_lookup_artist_tadb,
|
||||
mock_get_recording,
|
||||
mock_get_track,
|
||||
mock_get_album,
|
||||
client,
|
||||
mopidy_track,
|
||||
):
|
||||
build_scrobbles(client, mopidy_track.request_json)
|
||||
user = get_user_model().objects.first()
|
||||
count_dict = scrobble_counts(user)
|
||||
@ -41,8 +55,21 @@ def test_scrobble_counts_data(client, mopidy_track):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.get_album_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_track_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
|
||||
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||
@patch("music.models.lookup_album_from_tadb", return_value={})
|
||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||
def test_live_charts(client, mopidy_track):
|
||||
def test_live_charts(
|
||||
mock_lookup_album_tadb,
|
||||
mock_lookup_artist_tadb,
|
||||
mock_get_recording,
|
||||
mock_get_track,
|
||||
mock_get_album,
|
||||
client,
|
||||
mopidy_track,
|
||||
):
|
||||
build_scrobbles(client, mopidy_track.request_json, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
import pytest
|
||||
|
||||
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||
# from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to get local tests running working again")
|
||||
@pytest.mark.django_db
|
||||
def test_boardgame_log_data(boardgame_scrobble):
|
||||
assert not boardgame_scrobble.geo_location
|
||||
assert boardgame_scrobble.logdata == BoardGameLogData(
|
||||
players=[
|
||||
BoardGameScoreLogData(
|
||||
user_id=1,
|
||||
name_str="",
|
||||
person_id=1,
|
||||
bgg_username="",
|
||||
color="Blue",
|
||||
character=None,
|
||||
@ -18,10 +17,24 @@ def test_boardgame_log_data(boardgame_scrobble):
|
||||
score=30,
|
||||
win=True,
|
||||
new=None,
|
||||
)
|
||||
rank=None,
|
||||
seat_order=None,
|
||||
role=None,
|
||||
),
|
||||
BoardGameScoreLogData(
|
||||
person_id=2,
|
||||
bgg_username="",
|
||||
color="Red",
|
||||
character=None,
|
||||
team=None,
|
||||
score=28,
|
||||
win=False,
|
||||
new=None,
|
||||
rank=None,
|
||||
seat_order=None,
|
||||
role=None,
|
||||
),
|
||||
],
|
||||
location=None,
|
||||
geo_location_id=None,
|
||||
difficulty=None,
|
||||
solo=None,
|
||||
two_handed=None,
|
||||
|
||||
41
tests/scrobbles_tests/test_scrobblers.py
Normal file
41
tests/scrobbles_tests/test_scrobblers.py
Normal file
@ -0,0 +1,41 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from scrobbles.scrobblers import jellyfin_scrobble_media, mopidy_scrobble_media
|
||||
|
||||
|
||||
def test_jellyfin_scrobble_media_ignores_progress_with_zero_position():
|
||||
|
||||
post_data = {
|
||||
"ItemType": "Audio",
|
||||
"PlaybackPosition": "00:00:00",
|
||||
"NotificationType": "PlaybackProgress",
|
||||
}
|
||||
|
||||
result = jellyfin_scrobble_media(post_data, 1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_mopidy_scrobble_handles_missing_mopidy_uri():
|
||||
|
||||
with patch("scrobbles.scrobblers.Track") as mock_track_class:
|
||||
with patch("scrobbles.scrobblers.parse_mopidy_uri", return_value=None):
|
||||
mock_track = MagicMock()
|
||||
mock_track.scrobble_for_user = MagicMock(return_value=MagicMock())
|
||||
mock_track_class.find_or_create.return_value = mock_track
|
||||
|
||||
post_data = {
|
||||
"name": "Test Song",
|
||||
"artist": "Test Artist",
|
||||
"album": "Test Album",
|
||||
"run_time": 180000,
|
||||
}
|
||||
|
||||
result = mopidy_scrobble_media(post_data, 1)
|
||||
|
||||
mock_track_class.find_or_create.assert_called_once_with(
|
||||
title="Test Song",
|
||||
artist_name="Test Artist",
|
||||
album_name="Test Album",
|
||||
run_time_seconds=180000,
|
||||
)
|
||||
@ -1,13 +1,15 @@
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from django.urls import reverse
|
||||
from music.models import Track
|
||||
from music.models import Track, Artist, Album
|
||||
from podcasts.models import PodcastEpisode
|
||||
from scrobbles.models import Scrobble
|
||||
from tasks.models import Task
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -18,6 +20,14 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token):
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_not_allowed_from_jellyfin(client, valid_auth_token):
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.get(url, headers=headers)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
@ -30,6 +40,339 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bad_jellyfin_request_data(client, valid_auth_token):
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(url, headers)
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
response.data["detail"]
|
||||
== "JSON parse error - Expecting value: line 1 column 1 (char 0)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_create_scrobble_from_mopidy_track_webhook(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
mopidy_track,
|
||||
):
|
||||
mock_artist = MagicMock(spec=Artist)
|
||||
mock_artist.id = 1
|
||||
mock_artist_fc.return_value = mock_artist
|
||||
|
||||
mock_track = MagicMock(spec=Track)
|
||||
mock_track.id = 1
|
||||
mock_track.scrobble_for_user.return_value = Scrobble(
|
||||
id=1, track_id=1, user_id=1, in_progress=True
|
||||
)
|
||||
mock_track_fc.return_value = mock_track
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
mock_track.scrobble_for_user.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_create_scrobble_from_jellyfin_track_webhook(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
jellyfin_track,
|
||||
):
|
||||
mock_artist = MagicMock(spec=Artist)
|
||||
mock_artist.id = 1
|
||||
mock_artist_fc.return_value = mock_artist
|
||||
|
||||
mock_track = MagicMock(spec=Track)
|
||||
mock_track.id = 1
|
||||
mock_track.scrobble_for_user.return_value = Scrobble(
|
||||
id=1, track_id=1, user_id=1, in_progress=True
|
||||
)
|
||||
mock_track_fc.return_value = mock_track
|
||||
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
mock_track.scrobble_for_user.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_mopidy_track_webhook_creates_track_and_scrobble(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
mopidy_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Sublime")
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.track == track
|
||||
assert scrobble.source == "Mopidy"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_jellyfin_track_webhook_creates_track_and_scrobble(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
jellyfin_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Carly Rae Jepsen")
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.track == track
|
||||
assert scrobble.source == "Jellyfin"
|
||||
assert "raw_data" in scrobble.log
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_mopidy_track_webhook_stores_raw_data(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
mopidy_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Sublime")
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.track == track
|
||||
assert scrobble.source == "Mopidy"
|
||||
assert "raw_data" in scrobble.log
|
||||
assert scrobble.log["raw_data"]["name"] == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_mopidy_track_webhook_stores_album_id(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
mopidy_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Sublime")
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert "album_id" in scrobble.log
|
||||
assert scrobble.log["album_id"] == album.id
|
||||
assert "album" not in scrobble.log
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_jellyfin_track_webhook_stores_raw_data(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
jellyfin_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Carly Rae Jepsen")
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.track == track
|
||||
assert scrobble.source == "Jellyfin"
|
||||
assert "raw_data" in scrobble.log
|
||||
assert scrobble.log["raw_data"]["Name"] == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_jellyfin_track_webhook_stores_album_id(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
jellyfin_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Carly Rae Jepsen")
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert "album_id" in scrobble.log
|
||||
assert scrobble.log["album_id"] == album.id
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@patch(
|
||||
@ -105,6 +448,7 @@ def test_scrobble_mopidy_podcast(
|
||||
assert scrobble.media_obj.title == "Up First"
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@patch(
|
||||
@ -144,11 +488,128 @@ def test_scrobble_jellyfin_track(
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_detail_view_with_notes_as_flat_list(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Test Task", description="Test description"
|
||||
)
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
log={
|
||||
"notes": ["First note", "Second note"],
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert "First note" in response.content.decode()
|
||||
assert "Second note" in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Test Task", description="Test description"
|
||||
)
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
log={
|
||||
"notes": [
|
||||
{"2024-01-01 10:00:00": "Note at first timestamp"},
|
||||
{"2024-01-02 11:30:00": "Note at second timestamp"},
|
||||
],
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "2024-01-01 10:00:00" in content
|
||||
assert "Note at first timestamp" in content
|
||||
assert "2024-01-02 11:30:00" in content
|
||||
assert "Note at second timestamp" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_detail_view_with_notes_and_labels(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Test Task", description="Test description"
|
||||
)
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
log={
|
||||
"notes": [
|
||||
{"2024-01-01 10:00:00": "Note with label"},
|
||||
],
|
||||
"labels": ["work", "urgent"],
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "work" in content
|
||||
assert "urgent" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_detail_view_post_updates_log(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Test Task", description="Test description"
|
||||
)
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
log={
|
||||
"notes": ["Original note"],
|
||||
"description": "Original description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
url,
|
||||
{
|
||||
"description": "Updated description",
|
||||
"notes": "Updated note",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.log["description"] == "Updated description"
|
||||
assert scrobble.log["notes"] == ["Updated note"]
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@patch(
|
||||
@ -199,6 +660,7 @@ def test_scrobble_jellyfin_track_update(
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@patch(
|
||||
|
||||
95
tests/test_context_processors.py
Normal file
95
tests/test_context_processors.py
Normal file
@ -0,0 +1,95 @@
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from vrobbler.context_processors import version_info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class TestVersionInfo:
|
||||
def test_returns_version_and_commit(self, mock_request):
|
||||
with (
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.return_value = b"abc1234"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["app_version"] == "1.0.0"
|
||||
assert result["git_commit"] == "abc1234"
|
||||
|
||||
def test_uses_env_commit_if_set(self, mock_request):
|
||||
with (
|
||||
patch.dict(os.environ, {"VROBBLER_COMMIT": "env_commit_hash"}),
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "env_commit_hash"
|
||||
|
||||
def test_returns_unknown_when_version_fails(self, mock_request):
|
||||
with (
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.side_effect = Exception("not found")
|
||||
mock_check_output.return_value = b"abc1234"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["app_version"] == "unknown"
|
||||
|
||||
def test_returns_unknown_when_git_fails(self, mock_request):
|
||||
import subprocess
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.side_effect = subprocess.SubprocessError()
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "unknown"
|
||||
|
||||
def test_returns_unknown_when_git_not_found(self, mock_request):
|
||||
import subprocess
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.side_effect = FileNotFoundError()
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "unknown"
|
||||
104
tests/videos_tests/test_api.py
Normal file
104
tests/videos_tests/test_api.py
Normal file
@ -0,0 +1,104 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from videos.models import Channel, Series, Video
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
user = User.objects.create(email="api@test.com")
|
||||
token = Token.objects.create(user=user)
|
||||
return {"HTTP_AUTHORIZATION": f"Token {token.key}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def channel():
|
||||
return Channel.objects.create(name="Test Channel")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def series():
|
||||
return Series.objects.create(
|
||||
name="Test Series",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video(channel):
|
||||
return Video.objects.create(
|
||||
title="Test Video",
|
||||
imdb_id="tt1234567",
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestVideoAPI:
|
||||
def test_list_videos(self, client, auth_headers, video):
|
||||
response = client.get("/api/v1/videos/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["title"] == "Test Video"
|
||||
|
||||
def test_get_video(self, client, auth_headers, video):
|
||||
response = client.get(f"/api/v1/videos/{video.id}/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.data["title"] == "Test Video"
|
||||
|
||||
def test_filter_videos_by_channel(
|
||||
self, client, auth_headers, channel, video
|
||||
):
|
||||
response = client.get(
|
||||
f"/api/v1/videos/?channel={channel.id}", **auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["channel"] == channel.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestChannelAPI:
|
||||
def test_list_channels(self, client, auth_headers, channel):
|
||||
response = client.get("/api/v1/channels/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Test Channel"
|
||||
|
||||
def test_get_channel(self, client, auth_headers, channel):
|
||||
response = client.get(
|
||||
f"/api/v1/channels/{channel.id}/", **auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data["name"] == "Test Channel"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSeriesAPI:
|
||||
def test_list_series(self, client, auth_headers, series):
|
||||
response = client.get("/api/v1/series/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Test Series"
|
||||
|
||||
def test_get_series(self, client, auth_headers, series):
|
||||
response = client.get(f"/api/v1/series/{series.id}/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.data["name"] == "Test Series"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestVideoAPIUnauthorized:
|
||||
def test_list_videos_unauthenticated(self, client, video):
|
||||
response = client.get("/api/v1/videos/")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_list_channels_unauthenticated(self, client, channel):
|
||||
response = client.get("/api/v1/channels/")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_list_series_unauthenticated(self, client, series):
|
||||
response = client.get("/api/v1/series/")
|
||||
assert response.status_code == 401
|
||||
@ -1,6 +1,14 @@
|
||||
import pytest
|
||||
from videos.sources.imdb import lookup_video_from_imdb
|
||||
|
||||
|
||||
def test_lookup_imdb():
|
||||
@pytest.mark.skip(reason="Should not hit IMDB api in CI")
|
||||
def test_lookup_imdb_without_tt():
|
||||
metadata = lookup_video_from_imdb("8946378")
|
||||
assert not metadata.imdb_id
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Should not hit IMDB api in CI")
|
||||
def test_lookup_imdb_with_tt():
|
||||
metadata = lookup_video_from_imdb("tt8946378")
|
||||
assert metadata.title == "Knives Out"
|
||||
|
||||
484
todos.org
484
todos.org
@ -1,484 +0,0 @@
|
||||
#+title: TODOs
|
||||
|
||||
A fun way to keep track of things in the project to fix or improve.
|
||||
|
||||
* Version 0.16.0
|
||||
** TODO [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
|
||||
Effectively, any track that comes in without a MusicBrainz ID does some funky
|
||||
lookup where it doesn't find a track without an MB id and the track title /
|
||||
artist combination and creates a new track everytime. This has to be cleaend up
|
||||
by condensing the duplicated tracks into the original proper track.
|
||||
|
||||
But it opens a bigger quesiton about how much MB id should the drive the app
|
||||
lookup. If it can't be depended on to exist from all sources, it really can't be
|
||||
canonical. Instead, the combination of track title / artist is really the best
|
||||
we can do. Last.fm also has this problem, where it doesn't know about albums and
|
||||
definitely does not know or care about MB ids.
|
||||
|
||||
** DONE Add a user profile page with ability to change settings :profiles:improvement:
|
||||
- Note taken on [2025-04-04 Fri 10:51]
|
||||
[[orgit-rev:~/src/code.unbl.ink/secstate/vrobbler/::93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44][~/src/code.unbl.ink/secstate/vrobbler/ (magit-rev 93c16d8)]]
|
||||
|
||||
https://code.unbl.ink/secstate/vrobbler/commit/93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44
|
||||
|
||||
** DONE What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
|
||||
- Note taken on [2025-04-04 Fri 10:46]
|
||||
|
||||
Nothing. Over the last few months I built out a youtube model in videos and
|
||||
use a bookmarklet scrobbling pattern. Now web-scrobbler is just disabled for
|
||||
Youtube.
|
||||
|
||||
May want to revisit this at some point and only scrobble tracks from Youtube,
|
||||
because many people use YT for music listening.
|
||||
|
||||
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
|
||||
CLOSED: [2023-04-06 Thu 14:09]
|
||||
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
|
||||
CLOSED: [2023-04-02 Sun 23:58]
|
||||
|
||||
Essentially, we currently have the timestamp as when the content began
|
||||
scrobbling and then calculate the finish time from the length of the content.
|
||||
This works pretty well because we know how long most things are.
|
||||
|
||||
But in some cases, sports events or long podcasts, we may start mid-way through
|
||||
an event or finish halfway through but still want to mark it as done. In these
|
||||
cases, knowing the finish time could be useful, especially when interfacing with
|
||||
other scrobblers which may have different definitions of when a scrobble
|
||||
finishes or started.
|
||||
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
|
||||
CLOSED: [2023-03-27 Mon 20:18]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
|
||||
:END:
|
||||
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
|
||||
CLOSED: [2023-03-26 Sun 13:52]
|
||||
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
|
||||
CLOSED: [2023-03-26 Sun 13:51]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
|
||||
:END:
|
||||
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
|
||||
CLOSED: [2023-03-24 Fri 14:46]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
|
||||
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
|
||||
:END:
|
||||
** DONE Fix vrobbler settings not using booleans :settings:bug:
|
||||
CLOSED: [2023-03-24 Fri 10:45]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
|
||||
:END:
|
||||
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
|
||||
CLOSED: [2023-03-24 Fri 00:31]
|
||||
The live view will be blank every Monday, no reason to tie it to a day of the
|
||||
week. It should be "the last 7 days"
|
||||
** DONE [#B] Implement a detail view for TV shows :improvement:views:
|
||||
CLOSED: [2023-03-22 Wed 17:05]
|
||||
** DONE [#B] Implement a detail view for Movies :improvement:views:
|
||||
CLOSED: [2023-03-22 Wed 17:05]
|
||||
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
|
||||
CLOSED: [2023-03-22 Wed 17:04]
|
||||
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
|
||||
CLOSED: [2023-03-22 Wed 17:01]
|
||||
** DONE Add live chart view like Maloja :improvement:views:
|
||||
CLOSED: [2023-03-07 Tue 11:13]
|
||||
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
|
||||
CLOSED: [2023-03-22 Wed 17:06]
|
||||
|
||||
An example:
|
||||
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
|
||||
|
||||
This is actually going to be moot because we can import from LastFM, and
|
||||
web-scrobbler integrates well with LastFM. The only thing to think through here
|
||||
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
|
||||
from Youtube (all videos get pushed, sigh).
|
||||
|
||||
** DONE Add Amazon scraper to look up books when OL fails :books:improvement:
|
||||
This turned out to be a non-starter ... Amazon is aggressive at disallowing
|
||||
scraping quality. And all the OSS tools out there are stuck in an arms race
|
||||
trying to keep them from breaking.
|
||||
|
||||
That said, Google Books actually has a decent API (for now), and I've built this
|
||||
out using that.
|
||||
|
||||
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
|
||||
This was fixed a while ago, but there's a new manifested bug. Going to create a
|
||||
separate bug tracking ticket for that.
|
||||
* Version 0.11.4
|
||||
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
|
||||
CLOSED: [2023-03-07 Tue 11:11]
|
||||
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
|
||||
CLOSED: [2023-03-07 Tue 11:11]
|
||||
|
||||
** DONE [#A] Fix fetching artwork without release group :bug:
|
||||
CLOSED: [2023-01-29 Sun 14:27]
|
||||
|
||||
When we get artwork from Musicbrianz, and it's not found, we should check for
|
||||
release groups as well. This will stop issues with missing artwork because of
|
||||
obscure MB release matches.
|
||||
|
||||
** DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
|
||||
CLOSED: [2023-01-30 Mon 18:31]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
|
||||
:END:
|
||||
|
||||
If we play music from Jellyfin and the track reaches 90% completion, the
|
||||
scrobbling goes crazy and starts creating new scrobbles with every update.
|
||||
|
||||
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
|
||||
as complete for the following conditions:
|
||||
|
||||
- Play stopped and percent played beyond 90%
|
||||
- Play completely finished
|
||||
|
||||
But if we keep listening beyond 90, we should basically ignore updates (or just
|
||||
update the existing scrobble)
|
||||
** DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
|
||||
CLOSED: [2023-02-03 Fri 16:52]
|
||||
|
||||
An example of the format:
|
||||
#+begin_src csv
|
||||
,
|
||||
#AUDIOSCROBBLER/1.1
|
||||
#TZ/UNKNOWN
|
||||
#CLIENT/Rockbox sansaclipplus $Revision$
|
||||
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
|
||||
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
|
||||
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
||||
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
||||
311 311 Jackolantern’s Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
|
||||
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
|
||||
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
|
||||
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
|
||||
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
|
||||
,
|
||||
#+end_src
|
||||
** DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
|
||||
CLOSED: [2023-02-17 Fri 00:10]
|
||||
|
||||
This would allow a few nice flows. One, you'd be able to record the play of an
|
||||
entire album by just dropping the muscibrainz_id in. This could be helpful for
|
||||
offline listening. It would also mean bad metadata from mopidy would not break
|
||||
scrobbling.
|
||||
** DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
|
||||
CLOSED: [2023-02-17 Fri 00:11]
|
||||
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
|
||||
CLOSED: [2023-03-07 Tue 11:09]
|
||||
|
||||
Given a UUID from musicbrainz, we should be able to scrobble an album or
|
||||
individual track.
|
||||
|
||||
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
|
||||
CLOSED: [2023-03-07 Tue 11:10]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
|
||||
:END:
|
||||
|
||||
Maloja does this cool thing where artists and tracks get recorded as the top
|
||||
track of a given week, month or year. They get gold, silver or bronze stars for
|
||||
their place in the time period.
|
||||
|
||||
I could see this being implemented as a separate Chart table which gets
|
||||
populated at the end of a time period and has a start and end date that defines
|
||||
a period, along with a one, two, three instance.
|
||||
|
||||
Of course, it could also be a data model without a table, where it runs some fun
|
||||
calculations, stores it's values in Redis as a long-term lookup table and just
|
||||
has to re-populate when the server restarts.
|
||||
* Backlog
|
||||
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
|
||||
*** Example payloads from mopidy-webhooks
|
||||
**** Podcast playback ended
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_ended",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 13,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
}
|
||||
},
|
||||
"time_position": 3290
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Podcast playback state changes
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "playback_state_changed",
|
||||
"data": {
|
||||
"old_state": "paused",
|
||||
"new_state": "playing"
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "playback_state_changed",
|
||||
"data": {
|
||||
"old_state": "stopped",
|
||||
"new_state": "playing"
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Podcast playback started
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_started",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 13,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Podcast playback paused
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"state": "paused",
|
||||
"current_track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
},
|
||||
"time_position": 2350
|
||||
}
|
||||
}
|
||||
|
||||
#+end_src
|
||||
**** Track playback started
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_started",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 14,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Track playback in progress
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"state": "playing",
|
||||
"current_track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
},
|
||||
"time_position": 17031
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Track event playback paused
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_paused",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 14,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
}
|
||||
},
|
||||
"time_position": 67578
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
** TODO Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug:
|
||||
0
vrobbler/apps/beers/api/__init__.py
Normal file
0
vrobbler/apps/beers/api/__init__.py
Normal file
20
vrobbler/apps/beers/api/serializers.py
Normal file
20
vrobbler/apps/beers/api/serializers.py
Normal file
@ -0,0 +1,20 @@
|
||||
from rest_framework import serializers
|
||||
from beers.models import Beer, BeerProducer, BeerStyle
|
||||
|
||||
|
||||
class BeerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Beer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerProducer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerStyle
|
||||
fields = "__all__"
|
||||
21
vrobbler/apps/beers/api/views.py
Normal file
21
vrobbler/apps/beers/api/views.py
Normal file
@ -0,0 +1,21 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from beers.api import serializers
|
||||
from beers import models
|
||||
|
||||
|
||||
class BeerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Beer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BeerProducerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerProducer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerProducerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BeerStyleViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerStyle.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerStyleSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('beers', '0005_alter_beer_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='beer',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='beer',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='beer',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,18 +1,25 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from beers.untappd import get_beer_from_untappd_id, get_rating_from_soup
|
||||
from beers.untappd import get_beer_from_untappd_id
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BeerLogData
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeerLogData(BaseLogData):
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class BeerStyle(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from boardgames.models import BoardGame, BoardGamePublisher
|
||||
from boardgames.models import (
|
||||
BoardGame,
|
||||
BoardGameLocation,
|
||||
BoardGamePublisher,
|
||||
BoardGameDesigner,
|
||||
)
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
@ -15,13 +20,34 @@ class BoardGamePublisherAdmin(admin.ModelAdmin):
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGameDesigner)
|
||||
class BoardGameDesignerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"uuid",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGameLocation)
|
||||
class BoardGameLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"uuid",
|
||||
"geo_location",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGame)
|
||||
class GameAdmin(admin.ModelAdmin):
|
||||
class BoardGameAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"bggeek_id",
|
||||
"title",
|
||||
"published_date",
|
||||
"published_year",
|
||||
)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
|
||||
26
vrobbler/apps/boardgames/api/serializers.py
Normal file
26
vrobbler/apps/boardgames/api/serializers.py
Normal file
@ -0,0 +1,26 @@
|
||||
from boardgames import models
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class BoardGameDesignerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGameDesigner
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGamePublisherSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGamePublisher
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGameLocation
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGame
|
||||
fields = "__all__"
|
||||
28
vrobbler/apps/boardgames/api/views.py
Normal file
28
vrobbler/apps/boardgames/api/views.py
Normal file
@ -0,0 +1,28 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from boardgames.api import serializers
|
||||
from boardgames import models
|
||||
|
||||
|
||||
class BoardGameDesignerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGameDesigner.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameDesignerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGamePublisherViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGamePublisher.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGamePublisherSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameLocationViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGameLocation.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameLocationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGame.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
|
||||
User = get_user_model()
|
||||
if TYPE_CHECKING:
|
||||
@ -17,6 +18,11 @@ SEARCH_ID_URL = (
|
||||
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||
)
|
||||
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
|
||||
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
|
||||
BASE_HEADERS = {
|
||||
"User-Agent": "Vrobbler 31.0",
|
||||
"Authorization": f"Bearer {BGG_ACCESS_TOKEN}",
|
||||
}
|
||||
|
||||
|
||||
def take_first(thing: Optional[list]) -> str:
|
||||
@ -37,10 +43,9 @@ def take_first(thing: Optional[list]) -> str:
|
||||
|
||||
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||
soup = None
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
game_id = None
|
||||
url = SEARCH_ID_URL.format(query=title)
|
||||
r = requests.get(url, headers=headers)
|
||||
r = requests.get(url, headers=BASE_HEADERS)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
@ -57,7 +62,6 @@ def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
soup = None
|
||||
game_dict = {}
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
|
||||
title = ""
|
||||
bgg_id = None
|
||||
@ -73,7 +77,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
bgg_id = lookup_boardgame_id_from_bgg(title)
|
||||
|
||||
url = GAME_ID_URL.format(id=bgg_id)
|
||||
r = requests.get(url, headers=headers)
|
||||
r = requests.get(url, headers=BASE_HEADERS)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
@ -100,8 +104,8 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
|
||||
|
||||
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
bgg_username = user.profile.bgg_username
|
||||
bgg_password = user.profile.bgg_password
|
||||
bgg_username = "secstate" # user.profile.bgg_username
|
||||
bgg_password = "yYFCKnfo8AK89lc68q0S"
|
||||
|
||||
if not bgg_username or bgg_password:
|
||||
return
|
||||
@ -109,7 +113,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
login_payload = {
|
||||
"credentials": {"username": bgg_username, "password": bgg_password}
|
||||
}
|
||||
headers = {"content-type": "application/json"}
|
||||
headers = BASE_HEADERS
|
||||
headers["content-type"] = "application/json"
|
||||
|
||||
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
|
||||
|
||||
@ -119,24 +124,20 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
data=json.dumps(login_payload),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
players = []
|
||||
if scrobble.metadata:
|
||||
for player in scrobble.metadata.players:
|
||||
if player["user_id"]:
|
||||
player_user = User.objects.filter(
|
||||
id=player["user_id"]
|
||||
).first()
|
||||
if player_user:
|
||||
if player_user.bgg_username:
|
||||
player["username"] = player_user.bgg_username
|
||||
else:
|
||||
player["name"] = player_user.username
|
||||
player["win"] = player.get("win")
|
||||
player["color"] = player.get("color")
|
||||
player["new"] = player.get("new")
|
||||
player["score"] = player.get("score")
|
||||
players.append(player)
|
||||
if scrobble.log:
|
||||
for player in scrobble.log.get("players"):
|
||||
player_person = Person.objects.filter(
|
||||
id=player.get("person_id")
|
||||
).first()
|
||||
if player_person.get("bgg_username"):
|
||||
player["username"] = player_person.get("bgg_username")
|
||||
player["name"] = player_person.get("name")
|
||||
player["win"] = player.get("win")
|
||||
# player["role"] = player.get("role")
|
||||
player["new"] = player.get("new")
|
||||
player["score"] = player.get("score")
|
||||
players.append(player)
|
||||
|
||||
play_payload = {
|
||||
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
|
||||
@ -150,3 +151,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
"objecttype": "thing",
|
||||
"ajax": 1,
|
||||
}
|
||||
r = s.post(
|
||||
"https://boardgamegeek.com/geekplay.php",
|
||||
data=json.dumps(play_payload),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-03 01:57
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("locations", "0007_alter_geolocation_run_time_seconds"),
|
||||
("boardgames", "0007_alter_boardgame_run_time_seconds"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BoardGameDesigner",
|
||||
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"
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("bgg_id", models.IntegerField(blank=True, null=True)),
|
||||
("bio", models.TextField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="boardgamepublisher",
|
||||
old_name="igdb_id",
|
||||
new_name="bgg_id",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="bgstats_id",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="cooperative",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="expansion_for_boardgame",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="boardgames.boardgame",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="highest_wins",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="max_play_time",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="min_play_time",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="no_points",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="uses_teams",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BoardGameLocation",
|
||||
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"
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("bgstats_id", models.UUIDField(blank=True, null=True)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"geo_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="locations.geolocation",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="designers",
|
||||
field=models.ManyToManyField(
|
||||
related_name="board_games", to="boardgames.boardgamedesigner"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-03 02:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0008_boardgamedesigner_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="cooperative",
|
||||
field=models.BooleanField(blank=True, default=False, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="highest_wins",
|
||||
field=models.BooleanField(blank=True, default=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="no_points",
|
||||
field=models.BooleanField(blank=True, default=False, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="uses_teams",
|
||||
field=models.BooleanField(blank=True, default=False, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-03 04:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0009_alter_boardgame_cooperative_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="published_year",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0010_boardgame_published_year'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='boardgame',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='boardgame',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-11-03 04:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0011_remove_boardgame_run_time_seconds_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='bgg_rank',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-11-03 04:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0012_boardgame_bgg_rank'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='publishers',
|
||||
field=models.ManyToManyField(related_name='board_games', to='boardgames.boardgamepublisher'),
|
||||
),
|
||||
]
|
||||
@ -1,10 +1,13 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from functools import cached_property
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from boardgames.bgg import lookup_boardgame_from_bgg
|
||||
from boardgames.sources.bgg import lookup_boardgame_from_bgg
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
@ -12,18 +15,120 @@ from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BoardGameLogData
|
||||
from locations.models import GeoLocation
|
||||
from people.models import Person
|
||||
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoardGameScoreLogData(BaseLogData):
|
||||
person_id: Optional[int] = None
|
||||
bgg_username: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
character: Optional[str] = None
|
||||
team: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
win: Optional[bool] = None
|
||||
new: Optional[bool] = None
|
||||
rank: Optional[int] = None
|
||||
seat_order: Optional[int] = None
|
||||
role: Optional[str] = None
|
||||
rank: Optional[int] = None
|
||||
seat_order: Optional[int] = None
|
||||
role: Optional[str] = None
|
||||
lichess_username: Optional[str] = None
|
||||
|
||||
@property
|
||||
def person(self) -> Optional[Person]:
|
||||
return Person.objects.filter(id=self.person_id).first()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
name = ""
|
||||
if self.person:
|
||||
name = self.person.name
|
||||
return name
|
||||
|
||||
def __str__(self) -> str:
|
||||
out = self.name
|
||||
if self.score:
|
||||
out += f" {self.score}"
|
||||
if self.color:
|
||||
out += f" ({self.color})"
|
||||
if self.win:
|
||||
out += f" [W]"
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
players: Optional[list[BoardGameScoreLogData]] = None
|
||||
location_id: Optional[int] = None
|
||||
difficulty: Optional[int] = None
|
||||
solo: Optional[bool] = None
|
||||
two_handed: Optional[bool] = None
|
||||
expansion_ids: Optional[int] = None
|
||||
moves: Optional[list] = None
|
||||
rated: Optional[str] = None
|
||||
speed: Optional[str] = None
|
||||
variant: Optional[str] = None
|
||||
lichess_id: Optional[int] = None
|
||||
board: Optional[str] = None
|
||||
rounds: Optional[int] = None
|
||||
details: Optional[str] = None
|
||||
|
||||
_excluded_fields = {
|
||||
"lichess_id",
|
||||
"speed",
|
||||
"rated",
|
||||
"moves",
|
||||
"variant",
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def location(self):
|
||||
if not self.location_id:
|
||||
return
|
||||
return BoardGameLocation.objects.filter(id=self.location_id).first()
|
||||
|
||||
@cached_property
|
||||
def player_log(self) -> str:
|
||||
if self.players:
|
||||
return ", ".join(
|
||||
[
|
||||
BoardGameScoreLogData(**player).__str__()
|
||||
for player in self.players
|
||||
]
|
||||
)
|
||||
return ""
|
||||
|
||||
@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)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
|
||||
igdb_id = models.IntegerField(**BNULL)
|
||||
bgg_id = models.IntegerField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -34,6 +139,39 @@ class BoardGamePublisher(TimeStampedModel):
|
||||
)
|
||||
|
||||
|
||||
class BoardGameDesigner(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
bgg_id = models.IntegerField(**BNULL)
|
||||
bio = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:designer_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
|
||||
class BoardGameLocation(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
bgstats_id = models.UUIDField(**BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
geo_location = models.ForeignKey(
|
||||
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:location_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
|
||||
class BoardGame(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(
|
||||
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
||||
@ -53,6 +191,14 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher = models.ForeignKey(
|
||||
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
publishers = models.ManyToManyField(
|
||||
BoardGamePublisher,
|
||||
related_name="board_games",
|
||||
)
|
||||
designers = models.ManyToManyField(
|
||||
BoardGameDesigner,
|
||||
related_name="board_games",
|
||||
)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
|
||||
@ -82,11 +228,23 @@ class BoardGame(ScrobblableMixin):
|
||||
options={"quality": 75},
|
||||
)
|
||||
rating = models.FloatField(**BNULL)
|
||||
bgg_rank = models.IntegerField(**BNULL)
|
||||
max_players = models.PositiveSmallIntegerField(**BNULL)
|
||||
min_players = models.PositiveSmallIntegerField(**BNULL)
|
||||
published_date = models.DateField(**BNULL)
|
||||
published_year = models.IntegerField(**BNULL)
|
||||
recommended_age = models.PositiveSmallIntegerField(**BNULL)
|
||||
bggeek_id = models.CharField(max_length=255, **BNULL)
|
||||
bgstats_id = models.UUIDField(**BNULL)
|
||||
uses_teams = models.BooleanField(default=False, **BNULL)
|
||||
cooperative = models.BooleanField(default=False, **BNULL)
|
||||
highest_wins = models.BooleanField(default=True, **BNULL)
|
||||
no_points = models.BooleanField(default=False, **BNULL)
|
||||
min_play_time = models.IntegerField(**BNULL)
|
||||
max_play_time = models.IntegerField(**BNULL)
|
||||
expansion_for_boardgame = models.ForeignKey(
|
||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@ -128,7 +286,7 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher_name = data.pop("publisher_name")
|
||||
|
||||
if year:
|
||||
data["published_date"] = datetime(int(year), 1, 1)
|
||||
data["published_year"] = int(year)
|
||||
|
||||
if not data["min_players"]:
|
||||
data.pop("min_players")
|
||||
@ -148,29 +306,72 @@ class BoardGame(ScrobblableMixin):
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not self.cover:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
logger.debug(r.status_code)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
logger.debug("Loaded cover image from BGGeek")
|
||||
self.save_image_from_url(cover_url)
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, lookup_id: str, data: Optional[dict] = {}
|
||||
) -> Optional["BoardGame"]:
|
||||
cls, lookup_id: str, data: dict[str, Any] = {}
|
||||
) -> "BoardGame":
|
||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||
boardgame = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
if not game:
|
||||
game = cls.objects.filter(title=lookup_id).first()
|
||||
|
||||
if not data or not boardgame:
|
||||
data = lookup_boardgame_from_bgg(lookup_id)
|
||||
|
||||
if data and not boardgame:
|
||||
boardgame, created = cls.objects.get_or_create(
|
||||
title=data["title"], bggeek_id=lookup_id
|
||||
if game:
|
||||
logger.info(
|
||||
"Board game exists in database.",
|
||||
extra={"lookup_id": lookup_id, "data": data},
|
||||
)
|
||||
if created:
|
||||
boardgame.fix_metadata(data=data)
|
||||
return game
|
||||
|
||||
return boardgame
|
||||
if data.get("bggId"):
|
||||
bgg_data = lookup_boardgame_from_bgg(lookup_id=data.get("bggId"))
|
||||
elif data.get("name"):
|
||||
bgg_data = lookup_boardgame_from_bgg(title=data.get("name"))
|
||||
else:
|
||||
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
||||
|
||||
mechanics = bgg_data.pop("mechanics", [])
|
||||
designers = bgg_data.pop("designers", [])
|
||||
categories = bgg_data.pop("categories", [])
|
||||
publishers = bgg_data.pop("publishers", [])
|
||||
publisher = bgg_data.pop("publisher", [])
|
||||
cover_url = bgg_data.pop("cover_url")
|
||||
|
||||
game = cls.objects.create(**bgg_data)
|
||||
|
||||
game.save_image_from_url(cover_url)
|
||||
game.cooperative = data.get("cooperative", False)
|
||||
game.highest_wins = data.get("highestWins", True)
|
||||
game.no_points = data.get("noPoints", False)
|
||||
game.uses_teams = data.get("useTeams", False)
|
||||
game.bgstats_id = data.get("uuid", None)
|
||||
if publisher:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(
|
||||
name=publisher
|
||||
)
|
||||
game.publisher = publisher
|
||||
game.save()
|
||||
|
||||
if designers:
|
||||
for designer_name in designers:
|
||||
designer, created = BoardGameDesigner.objects.get_or_create(
|
||||
name=designer_name
|
||||
)
|
||||
game.designers.add(designer.id)
|
||||
|
||||
if publishers:
|
||||
for name in publishers:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(
|
||||
name=name
|
||||
)
|
||||
game.publishers.add(publisher)
|
||||
|
||||
return game
|
||||
|
||||
40
vrobbler/apps/boardgames/sources/bgg.py
Normal file
40
vrobbler/apps/boardgames/sources/bgg.py
Normal file
@ -0,0 +1,40 @@
|
||||
from typing import Any, Union
|
||||
from boardgamegeek import BGGClient
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def lookup_boardgame_from_bgg(
|
||||
lookup_id: str | None = None, title: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
game_dict: dict[str, Any] = {}
|
||||
|
||||
bgg = BGGClient(access_token=settings.BGG_ACCESS_TOKEN)
|
||||
|
||||
if lookup_id:
|
||||
game = bgg.game(game_id=lookup_id)
|
||||
else:
|
||||
game = bgg.game(title)
|
||||
|
||||
if game:
|
||||
game_dict["title"] = game.name
|
||||
game_dict["description"] = game.description
|
||||
game_dict["published_year"] = game.yearpublished
|
||||
game_dict["cover_url"] = game.image
|
||||
game_dict["min_players"] = game.minplayers
|
||||
game_dict["max_players"] = game.maxplayers
|
||||
game_dict["recommended_age"] = game.minage
|
||||
game_dict["rating"] = game.rating_average
|
||||
game_dict["bggeek_id"] = game.id
|
||||
game_dict["bgg_rank"] = game.bgg_rank
|
||||
game_dict["base_run_time_seconds"] = (
|
||||
int(game.playingtime) * 60 if game.playingtime else None
|
||||
)
|
||||
game_dict["mechanics"] = game.mechanics
|
||||
game_dict["categories"] = game.categories
|
||||
game_dict["designers"] = game.designers
|
||||
game_dict["publishers"] = game.publishers
|
||||
if game.publishers:
|
||||
game_dict["publisher"] = game.publishers[0]
|
||||
|
||||
return game_dict
|
||||
@ -3,7 +3,7 @@ from boardgames.models import BoardGame
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.notifications import NtfyNotification
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -18,9 +18,9 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
for game_dict in games:
|
||||
chess, created = BoardGame.objects.get_or_create(title="Chess")
|
||||
if created:
|
||||
chess.run_time_seconds = 1800
|
||||
chess.base_run_time_seconds = 1800
|
||||
chess.bggeek_id = 171
|
||||
chess.save(update_fields=["run_time_seconds", "bggeek_id"])
|
||||
chess.save(update_fields=["base_run_time_seconds", "bggeek_id"])
|
||||
scrobble = Scrobble.objects.filter(
|
||||
user_id=user.id,
|
||||
timestamp=game_dict.get("createdAt"),
|
||||
@ -124,5 +124,5 @@ def import_chess_games_for_all_users():
|
||||
if scrobbles_to_create:
|
||||
created = Scrobble.objects.bulk_create(scrobbles_to_create)
|
||||
for scrobble in created:
|
||||
NtfyNotification(scrobble).send()
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return scrobbles_to_create
|
||||
|
||||
@ -1,11 +1,68 @@
|
||||
import datetime
|
||||
from django.utils import timezone
|
||||
from django.views import generic
|
||||
from boardgames.models import BoardGame, BoardGamePublisher
|
||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
|
||||
class BoardGameListView(ScrobbleableListView):
|
||||
model = BoardGame
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
now = timezone.now()
|
||||
start_day_of_week = now - datetime.timedelta(days=now.weekday())
|
||||
start_day_of_month = now.replace(day=1)
|
||||
|
||||
scrobbles_this_week = Scrobble.objects.filter(
|
||||
user=user,
|
||||
board_game__isnull=False,
|
||||
timestamp__gte=start_day_of_week,
|
||||
).select_related("board_game")
|
||||
|
||||
scrobbles_this_month = Scrobble.objects.filter(
|
||||
user=user,
|
||||
board_game__isnull=False,
|
||||
timestamp__gte=start_day_of_month,
|
||||
).select_related("board_game")
|
||||
|
||||
designers_this_week = {}
|
||||
for scrobble in scrobbles_this_week:
|
||||
for designer in scrobble.board_game.designers.all():
|
||||
designers_this_week[designer.id] = {
|
||||
"designer": designer,
|
||||
"count": designers_this_week.get(designer.id, {}).get(
|
||||
"count", 0
|
||||
)
|
||||
+ 1,
|
||||
}
|
||||
|
||||
designers_this_month = {}
|
||||
for scrobble in scrobbles_this_month:
|
||||
for designer in scrobble.board_game.designers.all():
|
||||
designers_this_month[designer.id] = {
|
||||
"designer": designer,
|
||||
"count": designers_this_month.get(designer.id, {}).get(
|
||||
"count", 0
|
||||
)
|
||||
+ 1,
|
||||
}
|
||||
|
||||
context_data["designers_this_week"] = sorted(
|
||||
designers_this_week.values(),
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)
|
||||
context_data["designers_this_month"] = sorted(
|
||||
designers_this_month.values(),
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return context_data
|
||||
|
||||
|
||||
class BoardGameDetailView(ScrobbleableDetailView):
|
||||
model = BoardGame
|
||||
|
||||
@ -21,7 +21,8 @@ class BookAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"subtitle",
|
||||
"author",
|
||||
"issue_or_volume",
|
||||
"isbn_13",
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
@ -32,6 +33,9 @@ class BookAdmin(admin.ModelAdmin):
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
def issue_or_volume(self, obj):
|
||||
return obj.issue_number or obj.volume_number
|
||||
|
||||
|
||||
@admin.register(Paper)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from books.api.serializers import (
|
||||
AuthorSerializer,
|
||||
BookSerializer,
|
||||
)
|
||||
from books.models import Author, Book
|
||||
from books.api import serializers
|
||||
from books import models
|
||||
|
||||
|
||||
class AuthorViewSet(viewsets.ModelViewSet):
|
||||
queryset = Author.objects.all().order_by("-created")
|
||||
serializer_class = AuthorSerializer
|
||||
queryset = models.Author.objects.all().order_by("-created")
|
||||
serializer_class = serializers.AuthorSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BookViewSet(viewsets.ModelViewSet):
|
||||
queryset = Book.objects.all().order_by("-created")
|
||||
serializer_class = BookSerializer
|
||||
queryset = models.Book.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BookSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@ -5,3 +5,5 @@ BOOKS_TITLES_TO_IGNORE = [
|
||||
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
|
||||
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
|
||||
]
|
||||
|
||||
READCOMICSONLINE_URL = "https://readcomicsonline.ru"
|
||||
|
||||
@ -3,14 +3,14 @@ import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from books.constants import BOOKS_TITLES_TO_IGNORE
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import get_user_model
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
from stream_sqlite import stream_sqlite
|
||||
from scrobbles.notifications import NtfyNotification
|
||||
from webdav.client import get_webdav_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -114,7 +114,7 @@ def create_book_from_row(row: list):
|
||||
"raw_row_data": clean_row,
|
||||
}
|
||||
},
|
||||
run_time_seconds=run_time,
|
||||
base_run_time_seconds=run_time,
|
||||
)
|
||||
# TODO Move these to async processes after importing
|
||||
# book.fix_metadata()
|
||||
@ -139,7 +139,6 @@ def build_book_map(rows) -> dict:
|
||||
book_id_map = {}
|
||||
|
||||
for book_row in rows:
|
||||
|
||||
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
|
||||
logger.info(
|
||||
"[build_book_map] Ignoring book title that is likely garbage",
|
||||
@ -278,55 +277,19 @@ def build_scrobbles_from_book_map(
|
||||
)
|
||||
continue
|
||||
|
||||
timezone = user.profile.timezone
|
||||
timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(int(first_page.get("start_ts")))
|
||||
)
|
||||
stop_timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(int(last_page.get("end_ts")))
|
||||
)
|
||||
|
||||
timestamp = datetime.fromtimestamp(
|
||||
int(first_page.get("start_ts"))
|
||||
).replace(tzinfo=pytz.timezone(timezone))
|
||||
|
||||
# Add a shim here temporarily to fix imports while we were in France
|
||||
# if date is between 10/15 and 12/15, cast it to Europe/Central
|
||||
if (
|
||||
datetime(2023, 10, 15).replace(
|
||||
tzinfo=pytz.timezone("Europe/Paris")
|
||||
)
|
||||
<= timestamp
|
||||
<= datetime(2023, 12, 15).replace(
|
||||
tzinfo=pytz.timezone("Europe/Paris")
|
||||
)
|
||||
):
|
||||
timezone = "Europe/Paris"
|
||||
if (
|
||||
datetime(2024, 4, 28).replace(
|
||||
tzinfo=pytz.timezone("US/Pacific")
|
||||
)
|
||||
<= timestamp
|
||||
<= datetime(2024, 5, 4).replace(
|
||||
tzinfo=pytz.timezone("US/Pacific")
|
||||
)
|
||||
):
|
||||
timezone = "US/Pacific"
|
||||
if (
|
||||
datetime(2024, 8, 4).replace(
|
||||
tzinfo=pytz.timezone("Canada/Atlantic")
|
||||
)
|
||||
<= timestamp
|
||||
<= datetime(2024, 8, 10).replace(
|
||||
tzinfo=pytz.timezone("Canada/Atlantic")
|
||||
)
|
||||
):
|
||||
timezone = "Canada/Atlantic"
|
||||
|
||||
stop_timestamp = datetime.fromtimestamp(
|
||||
int(last_page.get("end_ts"))
|
||||
).replace(tzinfo=pytz.timezone(timezone))
|
||||
|
||||
if (
|
||||
timestamp.tzinfo._dst.seconds == 0
|
||||
or stop_timestamp.tzinfo._dst.seconds == 0
|
||||
):
|
||||
timestamp = timestamp - timedelta(hours=1)
|
||||
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||
# Adjust for Daylight Saving Time
|
||||
# if timestamp.dst() == timedelta(
|
||||
# 0
|
||||
# ) or stop_timestamp.dst() == timedelta(0):
|
||||
# timestamp = timestamp - timedelta(hours=1)
|
||||
# stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||
|
||||
scrobble = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
@ -343,6 +306,10 @@ def build_scrobbles_from_book_map(
|
||||
"page_data": scrobble_page_data,
|
||||
"pages_read": len(scrobble_page_data.keys()),
|
||||
}
|
||||
if hasattr(timestamp.tzinfo, "tzname"):
|
||||
tz = timestamp.tzinfo.tzname
|
||||
if hasattr(timestamp.tzinfo, "name"):
|
||||
tz = timestamp.tzinfo.name
|
||||
scrobbles_to_create.append(
|
||||
Scrobble(
|
||||
book_id=book_id,
|
||||
@ -356,7 +323,7 @@ def build_scrobbles_from_book_map(
|
||||
in_progress=False,
|
||||
played_to_completion=True,
|
||||
long_play_complete=False,
|
||||
timezone=timezone,
|
||||
timezone=tz,
|
||||
)
|
||||
)
|
||||
# Then start over
|
||||
@ -398,9 +365,9 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
|
||||
new_scrobbles = []
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
tz = pytz.utc
|
||||
tz = ZoneInfo("UTC")
|
||||
if user:
|
||||
tz = user.profile.timezone
|
||||
tz = user.profile.tzinfo
|
||||
|
||||
is_os_file = "https://" not in file_path
|
||||
if is_os_file:
|
||||
@ -443,7 +410,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
if new_scrobbles:
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
if created:
|
||||
NtfyNotification(created[-1]).send()
|
||||
ScrobbleNtfyNotification(created[-1]).send()
|
||||
fix_long_play_stats_for_scrobbles(created)
|
||||
logger.info(
|
||||
f"Created {len(created)} scrobbles",
|
||||
|
||||
@ -14,14 +14,13 @@ logger = logging.getLogger(__name__)
|
||||
# Grace period between page reads for it to be a new scrobble
|
||||
SESSION_GAP_SECONDS = 1800 # a half hour
|
||||
|
||||
|
||||
def update_scrobble_from_page_data(scrobble, commit=True):
|
||||
page_list = list(scrobble.book_page_data.items())
|
||||
first_page_start_ts = datetime.fromtimestamp(page_list[0][1]["start_ts"])
|
||||
last_page_end_ts = datetime.fromtimestamp(page_list[-1][1]["end_ts"])
|
||||
|
||||
if (
|
||||
datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15)
|
||||
):
|
||||
if datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15):
|
||||
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||
else:
|
||||
@ -38,5 +37,7 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
scrobbles_to_create = []
|
||||
|
||||
for scrobble in Scrobble.objects.filter(media_type="Book", source="KOReader"):
|
||||
for scrobble in Scrobble.objects.filter(
|
||||
media_type="Book", source="KOReader"
|
||||
):
|
||||
update_scrobble_from_page_data(scrobble)
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import pendulum
|
||||
|
||||
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
|
||||
IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
|
||||
IMDB_VIDEO_URL = "https://www.imdb.com/title/"
|
||||
|
||||
|
||||
class BookType:
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-20 18:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0028_delete_page'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='comicvine_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='issue_number',
|
||||
field=models.IntegerField(blank=True, max_length=5, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='original_title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='volume_number',
|
||||
field=models.IntegerField(blank=True, max_length=5, null=True),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/books/migrations/0030_book_readcomics_url.py
Normal file
18
vrobbler/apps/books/migrations/0030_book_readcomics_url.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-22 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0029_book_comicvine_id_book_issue_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='readcomics_url',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-22 17:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0030_book_readcomics_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='next_readcomics_url',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,39 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0031_book_next_readcomics_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='book',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='book',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paper',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paper',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paper',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-08 05:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0032_remove_book_run_time_seconds_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="issue_number",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="volume_number",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,19 @@
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
from datetime import timedelta, datetime
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from books.constants import READCOMICSONLINE_URL
|
||||
from books.openlibrary import (
|
||||
lookup_author_from_openlibrary,
|
||||
lookup_book_from_openlibrary,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.semantic import lookup_paper_from_semantic
|
||||
from books.utils import get_comic_issue_url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.base import ContentFile
|
||||
@ -16,28 +22,25 @@ from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
|
||||
from scrobbles.mixins import (
|
||||
LongPlayScrobblableMixin,
|
||||
ObjectWithGenres,
|
||||
ScrobblableConstants,
|
||||
ScrobblableMixin,
|
||||
)
|
||||
from scrobbles.utils import get_scrobbles_for_media
|
||||
from scrobbles.utils import get_scrobbles_for_media, next_url_if_exists
|
||||
from taggit.managers import TaggableManager
|
||||
from thefuzz import fuzz
|
||||
from vrobbler.apps.books.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
|
||||
from vrobbler.apps.books.locg import (
|
||||
lookup_comic_by_locg_slug,
|
||||
lookup_comic_from_locg,
|
||||
lookup_comic_writer_by_locg_slug,
|
||||
)
|
||||
from vrobbler.apps.books.sources.google import lookup_book_from_google
|
||||
from vrobbler.apps.books.sources.semantic import lookup_paper_from_semantic
|
||||
from vrobbler.apps.scrobbles.dataclasses import BookLogData
|
||||
from vrobbler.apps.books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
|
||||
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
|
||||
@ -46,6 +49,34 @@ User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookPageLogData(BaseLogData):
|
||||
page_number: Optional[int] = None
|
||||
end_ts: Optional[int] = None
|
||||
start_ts: Optional[int] = None
|
||||
duration: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookLogData(BaseLogData, LongPlayLogData):
|
||||
koreader_hash: Optional[str] = None
|
||||
page_data: Optional[dict[int, BookPageLogData]] = None
|
||||
pages_read: Optional[int] = None
|
||||
page_start: Optional[int] = None
|
||||
page_end: Optional[int] = None
|
||||
resume_url: Optional[str] = None
|
||||
|
||||
_excluded_fields = {"koreader_hash", "page_data"}
|
||||
|
||||
def avg_seconds_per_page(self):
|
||||
if self.page_data:
|
||||
total_duration = 0
|
||||
for page_num, stats in self.page_data.items():
|
||||
total_duration += stats.get("duration", 0)
|
||||
if total_duration:
|
||||
return int(total_duration / len(self.page_data))
|
||||
|
||||
|
||||
class Author(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
@ -107,6 +138,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
original_title = models.CharField(max_length=255, **BNULL)
|
||||
authors = models.ManyToManyField(Author, blank=True)
|
||||
koreader_data_by_hash = models.JSONField(**BNULL)
|
||||
isbn_13 = models.CharField(max_length=255, **BNULL)
|
||||
@ -117,6 +149,13 @@ class Book(LongPlayScrobblableMixin):
|
||||
publish_date = models.DateField(**BNULL)
|
||||
publisher = models.CharField(max_length=255, **BNULL)
|
||||
first_sentence = models.TextField(**BNULL)
|
||||
# ComicVine
|
||||
comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
issue_number = models.IntegerField(**BNULL)
|
||||
volume_number = models.IntegerField(**BNULL)
|
||||
# OpenLibrary
|
||||
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
||||
cover_small = ImageSpecField(
|
||||
@ -135,7 +174,11 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres)
|
||||
|
||||
def __str__(self):
|
||||
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}"
|
||||
|
||||
@property
|
||||
@ -161,44 +204,120 @@ class Book(LongPlayScrobblableMixin):
|
||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@classmethod
|
||||
def get_from_google(cls, title: str, overwrite: bool = False):
|
||||
def get_from_comicvine(
|
||||
cls, title: str, overwrite: bool = False, force_new: bool = False
|
||||
) -> "Book":
|
||||
book, created = cls.objects.get_or_create(title=title)
|
||||
if not created and not overwrite:
|
||||
|
||||
if not created:
|
||||
return book
|
||||
|
||||
book_dict = lookup_book_from_google(title)
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
|
||||
if created or overwrite:
|
||||
author_list = []
|
||||
authors = book_dict.pop("authors")
|
||||
cover_url = book_dict.pop("cover_url")
|
||||
try:
|
||||
genres = book_dict.pop("generes")
|
||||
except:
|
||||
genres = []
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
if author_str:
|
||||
author_dicts = book_dict.pop("author_dicts")
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
if author_dict.get("authorId"):
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
name=author_str
|
||||
semantic_id=author_dict.get("authorId")
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
# TODO enrich author
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
# TODO enrich author?
|
||||
...
|
||||
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
|
||||
if author_list:
|
||||
book.authors.add(*author_list)
|
||||
genres = book_dict.pop("genres", [])
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
return book
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls,
|
||||
title: str,
|
||||
url: str = "",
|
||||
enrich: bool = False,
|
||||
commit: bool = True,
|
||||
):
|
||||
"""Given a title, get a Book instance.
|
||||
|
||||
If the book is not already in our database, or overwrite is True,
|
||||
this method will enrich the Book with data from Google.
|
||||
|
||||
By default this method will also save the data back to the model. If you'd
|
||||
like to batch create, use commit=False and you'll get an unsaved but enriched
|
||||
instance back which you can then save at your convenience."""
|
||||
# TODO use either a Google Books id identifier or author name like for tracks
|
||||
book, created = cls.objects.get_or_create(original_title=title)
|
||||
if not created:
|
||||
logger.info(
|
||||
"Found exact match for book by title", extra={"title": title}
|
||||
)
|
||||
|
||||
if not enrich:
|
||||
logger.info(
|
||||
"Found book by title, but not enriching",
|
||||
extra={"title": title},
|
||||
)
|
||||
return book
|
||||
|
||||
book_dict = None
|
||||
if READCOMICSONLINE_URL in url:
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(
|
||||
book_dict["readcomics_url"]
|
||||
)
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_google(title)
|
||||
|
||||
if not book_dict:
|
||||
logger.warning(
|
||||
"No book found in any source, using data as is",
|
||||
extra={"title": title},
|
||||
)
|
||||
|
||||
author_list = []
|
||||
authors = book_dict.pop("authors", [])
|
||||
cover_url = book_dict.pop("cover_url", "")
|
||||
genres = book_dict.pop("generes", [])
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
if author_str:
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
name=author_str
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
# TODO enrich author
|
||||
...
|
||||
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
|
||||
if commit:
|
||||
book.save()
|
||||
|
||||
book.save_image_from_url(cover_url)
|
||||
book.genre.add(*genres)
|
||||
book.authors.add(*author_list)
|
||||
|
||||
return book
|
||||
|
||||
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||
if not self.cover or (force_update and url):
|
||||
if url and (not self.cover or force_update):
|
||||
r = requests.get(url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
@ -294,7 +413,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if self.pages:
|
||||
self.run_time_seconds = int(self.pages) * int(
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
|
||||
@ -378,27 +497,6 @@ class Book(LongPlayScrobblableMixin):
|
||||
progress = int((last_scrobble.last_page_read / self.pages) * 100)
|
||||
return progress
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
|
||||
book = cls.objects.filter(openlibrary_id=lookup_id).first()
|
||||
|
||||
if not book:
|
||||
data = lookup_book_from_openlibrary(lookup_id, author)
|
||||
|
||||
if not data:
|
||||
logger.error(
|
||||
f"No book found on openlibrary, or in our database for {lookup_id}"
|
||||
)
|
||||
return book
|
||||
|
||||
book, book_created = cls.objects.get_or_create(
|
||||
isbn_13=data["isbn"]
|
||||
)
|
||||
if book_created:
|
||||
book.fix_metadata(data=data)
|
||||
|
||||
return book
|
||||
|
||||
|
||||
class Paper(LongPlayScrobblableMixin):
|
||||
"""Keeps track of Academic Papers"""
|
||||
|
||||
@ -3,7 +3,6 @@ ComicVine API Information & Documentation:
|
||||
https://comicvine.gamespot.com/api/
|
||||
https://comicvine.gamespot.com/api/documentation
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from django.conf import settings
|
||||
|
||||
@ -200,34 +199,72 @@ class ComicVineClient(object):
|
||||
|
||||
|
||||
def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
original_title = title
|
||||
|
||||
issue_number = None
|
||||
volume_nubmer = None
|
||||
resource_type = "issue"
|
||||
if "Issue " in title:
|
||||
resource_type = "issue"
|
||||
issue_number = title.split("Issue ")[1]
|
||||
volume_number = None
|
||||
if "Volume " in title:
|
||||
resource_type = "volume"
|
||||
volume_number = title.split("Volume ")[1]
|
||||
|
||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warn("No ComicVine API key configured, not looking anything up")
|
||||
logger.warning(
|
||||
"No ComicVine API key configured, not looking anything up"
|
||||
)
|
||||
return {}
|
||||
|
||||
client = ComicVineClient(
|
||||
api_key=getattr(settings, "COMICVINE_API_KEY", None)
|
||||
)
|
||||
result = [
|
||||
r
|
||||
for r in client.search(title).get("results")
|
||||
if r.get("resource_type") == "volume"
|
||||
][0]
|
||||
|
||||
if "volume" not in result.keys():
|
||||
logger.warn("No result found on ComicVine", extra={"title": title})
|
||||
raw_results = client.search(title).get("results")
|
||||
results = [
|
||||
r for r in raw_results if r.get("resource_type") == resource_type
|
||||
]
|
||||
if not results:
|
||||
logger.warning("No comic found on ComicVine")
|
||||
return {}
|
||||
|
||||
title = " ".join([result.get("volume").get("name"), result.get("name)")])
|
||||
found_result = None
|
||||
for result in results:
|
||||
if result.get("issue_number") == str(issue_number):
|
||||
found_result = result
|
||||
break
|
||||
if result.get("volume_number") == str(volume_number):
|
||||
found_result = result
|
||||
break
|
||||
|
||||
if not found_result:
|
||||
found_result = results[0]
|
||||
|
||||
logger.info("ComicVine results", extra={"results": results})
|
||||
|
||||
if not found_result:
|
||||
logger.warning("No matches found on ComicVine")
|
||||
return {}
|
||||
|
||||
title = found_result.get("name")
|
||||
|
||||
if found_result.get("volume"):
|
||||
title = found_result.get("volume").get("name")
|
||||
|
||||
data_dict = {
|
||||
"title": title,
|
||||
"cover_url": result.get("image").get("original_url"),
|
||||
"comicvine_data": {
|
||||
"id": result.get("id"),
|
||||
"site_detail_url": result.get("site_detail_url"),
|
||||
"description": result.get("description"),
|
||||
"image": result.get("image").get("original_url"),
|
||||
},
|
||||
"original_title": original_title,
|
||||
"issue_number": found_result.get("issue_number"),
|
||||
"volume_number": found_result.get("volume_number"),
|
||||
"cover_url": found_result.get("image").get("original_url"),
|
||||
"comicvine_id": found_result.get("id"),
|
||||
"comicvine_data": found_result,
|
||||
"summary": found_result.get("description"),
|
||||
"publish_date": found_result.get("cover_date"),
|
||||
"first_publish_year": found_result.get("cover_date", "")[:4],
|
||||
}
|
||||
|
||||
return data_dict
|
||||
@ -7,7 +7,7 @@ from django.conf import settings
|
||||
|
||||
API_KEY = settings.GOOGLE_API_KEY
|
||||
GOOGLE_BOOKS_URL = (
|
||||
"https://www.googleapis.com/books/v1/volumes?q=\"{title}\"&key={key}"
|
||||
'https://www.googleapis.com/books/v1/volumes?q="{title}"&key={key}'
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -29,6 +29,9 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
google_result = (
|
||||
json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
|
||||
)
|
||||
if not google_result:
|
||||
return {}
|
||||
|
||||
publish_date = pendulum.parse(google_result.get("publishedDate"))
|
||||
|
||||
isbn_13 = ""
|
||||
@ -59,13 +62,15 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
book_dict["genres"] = google_result.get("categories")
|
||||
book_dict["cover_url"] = (
|
||||
google_result.get("imageLinks", {})
|
||||
.get("thumbnail")
|
||||
.get("thumbnail", "")
|
||||
.replace("zoom=1", "zoom=15")
|
||||
.replace("&edge=curl", "")
|
||||
)
|
||||
|
||||
book_dict["run_time_seconds"] = book_dict.get("pages", 10) * getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
book_dict["base_run_time_seconds"] = 3600
|
||||
if book_dict.get("pages"):
|
||||
book_dict["base_run_time_seconds"] = book_dict.get(
|
||||
"pages", 10
|
||||
) * getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
|
||||
|
||||
return book_dict
|
||||
|
||||
@ -67,9 +67,9 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
|
||||
"url"
|
||||
)
|
||||
paper_dict["run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
paper_dict["base_run_time_seconds"] = paper_dict.get(
|
||||
"pages", 10
|
||||
) * getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
|
||||
paper_dict["author_dicts"] = result.get("authors")
|
||||
paper_dict["genres"] = result.get("fieldsOfStudy")
|
||||
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import pytest
|
||||
from unittest import mock
|
||||
from datetime import datetime
|
||||
|
||||
from books.koreader import (
|
||||
KoReaderBookColumn,
|
||||
build_book_map,
|
||||
build_page_data,
|
||||
build_scrobbles_from_book_map,
|
||||
get_author_str_from_row,
|
||||
lookup_or_create_authors_from_author_str,
|
||||
create_book_from_row,
|
||||
process_koreader_sqlite_file,
|
||||
)
|
||||
|
||||
|
||||
@ -52,3 +57,157 @@ def test_build_scrobbles_from_pages(
|
||||
assert len(scrobbles[3].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[4].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[5].logdata.page_data.keys()) == 18
|
||||
|
||||
|
||||
def test_get_author_str_from_row():
|
||||
row = [
|
||||
1,
|
||||
"Test Book",
|
||||
"John Smith\nJane Doe",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
"",
|
||||
"",
|
||||
"abc123",
|
||||
0,
|
||||
120,
|
||||
]
|
||||
result = get_author_str_from_row(row)
|
||||
assert result == "John Smith, Jane Doe"
|
||||
|
||||
|
||||
def test_get_author_str_from_row_strips_middle_initials():
|
||||
row = [
|
||||
1,
|
||||
"Test Book",
|
||||
"John A. Smith",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
"",
|
||||
"",
|
||||
"abc123",
|
||||
0,
|
||||
120,
|
||||
]
|
||||
result = get_author_str_from_row(row)
|
||||
assert result == "John Smith"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lookup_or_create_authors_from_author_str_creates_new_author():
|
||||
author = lookup_or_create_authors_from_author_str("New Author")
|
||||
assert len(author) == 1
|
||||
assert author[0].name == "New Author"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lookup_or_create_authors_from_author_str_finds_existing():
|
||||
from books.models import Author
|
||||
|
||||
Author.objects.create(name="Existing Author")
|
||||
author = lookup_or_create_authors_from_author_str("Existing Author")
|
||||
assert len(author) == 1
|
||||
assert author[0].name == "Existing Author"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lookup_or_create_authors_from_author_str_ignores_na():
|
||||
author = lookup_or_create_authors_from_author_str("N/A")
|
||||
assert len(author) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lookup_or_create_authors_from_author_str_handles_multiple():
|
||||
author = lookup_or_create_authors_from_author_str("Author One, Author Two")
|
||||
assert len(author) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_book_from_row():
|
||||
row = [
|
||||
1,
|
||||
"Test Book - Author Name",
|
||||
"Test Author",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
"",
|
||||
"",
|
||||
"abc123def456",
|
||||
3600,
|
||||
120,
|
||||
]
|
||||
book = create_book_from_row(row)
|
||||
assert book.title == "Test Book"
|
||||
assert book.pages == 300
|
||||
assert "abc123def456" in book.koreader_data_by_hash
|
||||
assert book.authors.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_book_from_row_handles_title_with_author_suffix():
|
||||
row = [
|
||||
1,
|
||||
"Test Book - Author_Name",
|
||||
"N/A",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
"",
|
||||
"",
|
||||
"abc123def456",
|
||||
3600,
|
||||
120,
|
||||
]
|
||||
book = create_book_from_row(row)
|
||||
assert book.title == "Test Book"
|
||||
assert book.authors.count() == 1
|
||||
assert book.authors.first().name == "Author"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_book_from_row_handles_null_bytes():
|
||||
row = [
|
||||
1,
|
||||
"Test\x00Book",
|
||||
"Test\x00Author",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
"",
|
||||
"",
|
||||
"abc123def456",
|
||||
3600,
|
||||
120,
|
||||
]
|
||||
book = create_book_from_row(row)
|
||||
assert "TestBook" in book.title
|
||||
assert "TestAuthor" in book.authors.first().name
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_build_book_map_ignores_garbage_titles(koreader_rows):
|
||||
garbage_row = [
|
||||
999,
|
||||
"KOReader Quickstart Guide",
|
||||
"N/A",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
"garbage123",
|
||||
0,
|
||||
0,
|
||||
]
|
||||
book_map = build_book_map(koreader_rows.BOOK_ROWS + [garbage_row])
|
||||
assert len(book_map) == 1
|
||||
assert 999 not in book_map
|
||||
|
||||
61
vrobbler/apps/books/utils.py
Normal file
61
vrobbler/apps/books/utils.py
Normal file
@ -0,0 +1,61 @@
|
||||
import re
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from titlecase import titlecase
|
||||
|
||||
|
||||
def parse_readcomicsonline_uri(uri: str) -> tuple:
|
||||
try:
|
||||
path = uri.split("comic/")[1]
|
||||
except IndexError:
|
||||
return "", "", ""
|
||||
|
||||
parts = path.split("/")
|
||||
title = ""
|
||||
volume = 1
|
||||
page = 1
|
||||
if len(parts) == 2:
|
||||
title = titlecase(parts[0].replace("-", " "))
|
||||
volume = parts[1]
|
||||
if len(parts) == 3:
|
||||
title = titlecase(parts[0].replace("-", " "))
|
||||
volume = parts[1]
|
||||
page = parts[2]
|
||||
|
||||
return title, volume, page
|
||||
|
||||
|
||||
def get_comic_issue_url(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
parts = [p for p in parsed.path.strip("/").split("/") if p]
|
||||
|
||||
# Find the index of "comic"
|
||||
try:
|
||||
comic_index = parts.index("comic")
|
||||
except ValueError:
|
||||
raise ValueError("URL does not contain '/comic/' segment")
|
||||
|
||||
# Extract title (next part after 'comic')
|
||||
if len(parts) <= comic_index + 1:
|
||||
raise ValueError("No comic title found after '/comic/'")
|
||||
title = parts[comic_index + 1]
|
||||
|
||||
# Look for the first numeric segment after the title
|
||||
number = None
|
||||
for segment in parts[comic_index + 2 :]:
|
||||
if segment.isdigit():
|
||||
number = segment
|
||||
break
|
||||
|
||||
# Build normalized path
|
||||
new_parts = ["comic", title]
|
||||
if number:
|
||||
new_parts.append(number)
|
||||
|
||||
normalized_path = "/" + "/".join(new_parts)
|
||||
|
||||
# Rebuild full URL (same scheme and host)
|
||||
simplified_url = urlunparse(
|
||||
parsed._replace(path=normalized_path, query="", fragment="")
|
||||
)
|
||||
return simplified_url
|
||||
0
vrobbler/apps/bricksets/api/__init__.py
Normal file
0
vrobbler/apps/bricksets/api/__init__.py
Normal file
8
vrobbler/apps/bricksets/api/serializers.py
Normal file
8
vrobbler/apps/bricksets/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
from bricksets.models import BrickSet
|
||||
|
||||
|
||||
class BrickSetSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BrickSet
|
||||
fields = "__all__"
|
||||
9
vrobbler/apps/bricksets/api/views.py
Normal file
9
vrobbler/apps/bricksets/api/views.py
Normal file
@ -0,0 +1,9 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from bricksets.api.serializers import BrickSetSerializer
|
||||
from bricksets.models import BrickSet
|
||||
|
||||
|
||||
class BrickSetViewSet(viewsets.ModelViewSet):
|
||||
queryset = BrickSet.objects.all().order_by("-created")
|
||||
serializer_class = BrickSetSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bricksets', '0002_alter_brickset_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='brickset',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='brickset',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='brickset',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,22 +1,34 @@
|
||||
from django.apps import apps
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BrickSetLogData
|
||||
from scrobbles.mixins import LongPlayScrobblableMixin
|
||||
|
||||
from vrobbler.apps.scrobbles.dataclasses import (
|
||||
BaseLogData,
|
||||
LongPlayLogData,
|
||||
WithPeopleLogData,
|
||||
)
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrickSetLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
|
||||
pass
|
||||
|
||||
|
||||
class BrickSet(LongPlayScrobblableMixin):
|
||||
""""""
|
||||
|
||||
number = models.CharField(max_length=10, **BNULL)
|
||||
release_year = models.IntegerField(**BNULL)
|
||||
piece_count = models.IntegerField(**BNULL)
|
||||
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
|
||||
brickset_rating = models.DecimalField(
|
||||
max_digits=3, decimal_places=1, **BNULL
|
||||
)
|
||||
lego_item_number = models.CharField(max_length=10, **BNULL)
|
||||
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
|
||||
box_image_small = ImageSpecField(
|
||||
@ -48,13 +60,23 @@ class BrickSet(LongPlayScrobblableMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def __str__(self) -> str:
|
||||
name = str(self.title)
|
||||
if not self.title:
|
||||
name = str(self.number)
|
||||
return name
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BrickSetLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, title: str) -> "BrickSet":
|
||||
return cls.objects.filter(title=title).first()
|
||||
def find_or_create(cls, brickset_id: str) -> "BrickSet":
|
||||
brickset = cls.objects.filter(number=brickset_id).first()
|
||||
if not brickset:
|
||||
# TODO: enrich this from the website
|
||||
brickset = cls.objects.create(number=brickset_id)
|
||||
return brickset
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
|
||||
0
vrobbler/apps/foods/api/__init__.py
Normal file
0
vrobbler/apps/foods/api/__init__.py
Normal file
14
vrobbler/apps/foods/api/serializers.py
Normal file
14
vrobbler/apps/foods/api/serializers.py
Normal file
@ -0,0 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
from foods import models
|
||||
|
||||
|
||||
class FoodCategorySerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.FoodCategory
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class FoodSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Food
|
||||
fields = "__all__"
|
||||
21
vrobbler/apps/foods/api/views.py
Normal file
21
vrobbler/apps/foods/api/views.py
Normal file
@ -0,0 +1,21 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from foods.api.serializers import (
|
||||
FoodSerializer,
|
||||
FoodCategorySerializer,
|
||||
)
|
||||
from foods.models import (
|
||||
FoodCategory,
|
||||
Food,
|
||||
)
|
||||
|
||||
|
||||
class FoodCategoryViewSet(viewsets.ModelViewSet):
|
||||
queryset = FoodCategory.objects.all().order_by("-created")
|
||||
serializer_class = FoodCategorySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class FoodViewSet(viewsets.ModelViewSet):
|
||||
queryset = Food.objects.all().order_by("-created")
|
||||
serializer_class = FoodSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
3
vrobbler/apps/foods/constants.py
Normal file
3
vrobbler/apps/foods/constants.py
Normal file
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
EXCLUDED_FOOD_DOMAINS = ["bbc.co.uk", "bbc.com", "www.bbc.com"]
|
||||
18
vrobbler/apps/foods/migrations/0003_food_calories.py
Normal file
18
vrobbler/apps/foods/migrations/0003_food_calories.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-09-11 13:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('foods', '0002_alter_food_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='calories',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('foods', '0003_food_calories'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='food',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='food',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,131 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-05 17:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("foods", "0004_remove_food_run_time_seconds_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="carbohydrates",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="cook_time_minutes",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="fat",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="fiber",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="ingredients",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="instructions",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="prep_time_minutes",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="protein",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="servings",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="sodium",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="source_site",
|
||||
field=models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="source_url",
|
||||
field=models.URLField(blank=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="sugar",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="total_time_minutes",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="food",
|
||||
name="yield_text",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="food",
|
||||
index=models.Index(
|
||||
fields=["source_url"], name="foods_food_source__f42f6f_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="food",
|
||||
index=models.Index(
|
||||
fields=["allrecipe_id"], name="foods_food_allreci_3e7a6c_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="food",
|
||||
index=models.Index(
|
||||
fields=["description"], name="foods_food_descrip_fccd4d_idx"
|
||||
),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="food",
|
||||
old_name="allrecipe_image",
|
||||
new_name="recipe_image",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="foodcategory",
|
||||
name="allrecipe_image",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="foodcategory",
|
||||
name="category_image",
|
||||
field=models.ImageField(blank=True, null=True, upload_to="food/category/"),
|
||||
),
|
||||
]
|
||||
@ -1,3 +1,6 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
from django.apps import apps
|
||||
@ -6,24 +9,32 @@ from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import FoodLogData
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
from foods.sources.rscraper import RecipeScraperService
|
||||
from foods.sources.usda import NutritionCalculator
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FoodLogData(BaseLogData, WithPeopleLogData):
|
||||
calories: Optional[int] = None
|
||||
meal: Optional[str] = None
|
||||
|
||||
|
||||
class FoodCategory(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
|
||||
allrecipe_image_small = ImageSpecField(
|
||||
source="recipe_image",
|
||||
category_image = models.ImageField(upload_to="food/category/", **BNULL)
|
||||
category_image_small = ImageSpecField(
|
||||
source="category_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
allrecipe_image_medium = ImageSpecField(
|
||||
source="recipe_image",
|
||||
category_image_medium = ImageSpecField(
|
||||
source="category_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
@ -31,6 +42,7 @@ class FoodCategory(TimeStampedModel):
|
||||
allrecipe_id = models.CharField(max_length=255, **BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, title: str) -> "FoodCategory":
|
||||
return cls.objects.filter(title=title).first()
|
||||
|
||||
@ -39,16 +51,42 @@ class FoodCategory(TimeStampedModel):
|
||||
|
||||
|
||||
class Food(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
|
||||
allrecipe_image_small = ImageSpecField(
|
||||
source="allrecipe_image",
|
||||
# Recipe source tracking
|
||||
source_url = models.URLField(null=True, blank=True, unique=True)
|
||||
source_site = models.CharField(max_length=100, null=True, blank=True)
|
||||
|
||||
# Recipe data
|
||||
description = models.TextField(**BNULL) # Recipe title
|
||||
ingredients = models.TextField(**BNULL) # JSON or newline-separated
|
||||
instructions = models.TextField(**BNULL) # JSON or newline-separated
|
||||
prep_time_minutes = models.IntegerField(**BNULL)
|
||||
cook_time_minutes = models.IntegerField(**BNULL)
|
||||
total_time_minutes = models.IntegerField(**BNULL)
|
||||
servings = models.IntegerField(**BNULL)
|
||||
yield_text = models.CharField(
|
||||
max_length=255, **BNULL
|
||||
) # e.g., "8 calzones"
|
||||
|
||||
# Nutrition (per serving)
|
||||
calories = models.IntegerField(**BNULL)
|
||||
protein = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
||||
fat = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
||||
carbohydrates = models.DecimalField(
|
||||
max_digits=10, decimal_places=2, **BNULL
|
||||
)
|
||||
fiber = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
||||
sugar = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
||||
sodium = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
|
||||
|
||||
recipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
|
||||
recipe_image_small = ImageSpecField(
|
||||
source="recipe_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
allrecipe_image_medium = ImageSpecField(
|
||||
source="allrecipe_image",
|
||||
recipe_image_medium = ImageSpecField(
|
||||
source="recipe_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
@ -59,12 +97,20 @@ class Food(ScrobblableMixin):
|
||||
FoodCategory, on_delete=models.DO_NOTHING, **BNULL
|
||||
)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["source_url"]),
|
||||
models.Index(fields=["allrecipe_id"]),
|
||||
models.Index(fields=["description"]),
|
||||
]
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("foods:food_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.category.name
|
||||
if self.category:
|
||||
return self.category.name
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
@ -81,8 +127,8 @@ class Food(ScrobblableMixin):
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.allrecipe_image:
|
||||
url = self.allrecipe_image.url
|
||||
if self.recipe_image:
|
||||
url = self.recipe_image.url
|
||||
return url
|
||||
|
||||
@property
|
||||
@ -90,21 +136,155 @@ class Food(ScrobblableMixin):
|
||||
return FoodLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, allrecipe_id: str) -> "Food":
|
||||
food = cls.objects.filter(allrecipe_id=allrecipe_id).first()
|
||||
def find_or_create_from_recipe(
|
||||
cls, url: str, category=None
|
||||
) -> Tuple["Food", bool]:
|
||||
"""
|
||||
Scrape a recipe URL and create/update Food instance.
|
||||
Uses django-taggit for tags and existing FoodCategory for category.
|
||||
"""
|
||||
# Check if URL already exists
|
||||
existing = cls.objects.filter(source_url=url).first()
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
if not food:
|
||||
food_dict = get_food_from_allrecipe_id(allrecipe_id)
|
||||
# category_dict = {}
|
||||
# Scrape the recipe
|
||||
scraper = RecipeScraperService()
|
||||
recipe_data = scraper.scrape_url(url)
|
||||
|
||||
# category, _created = FoodCategory.objects.get_or_create(
|
||||
# **category_dict
|
||||
# )
|
||||
food = Food.objects.create(**food_dict)
|
||||
# for category_id in category_ids:
|
||||
# food.category.add(category_id)
|
||||
# Download image
|
||||
recipe_image = None
|
||||
if recipe_data.get("image"):
|
||||
recipe_image = scraper.download_image(recipe_data["image"])
|
||||
|
||||
return food
|
||||
# Extract tags
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
|
||||
# Calculate nutrition
|
||||
nutrition = recipe_data.get("nutrition")
|
||||
if not nutrition:
|
||||
calculator = NutritionCalculator()
|
||||
servings = (
|
||||
scraper.parse_servings(recipe_data.get("yields", "1")) or 1
|
||||
)
|
||||
nutrition = calculator.calculate_nutrition(
|
||||
recipe_data.get("ingredients", []), servings
|
||||
)
|
||||
else:
|
||||
servings = (
|
||||
scraper.parse_servings(recipe_data.get("yields", "1")) or 1
|
||||
)
|
||||
|
||||
# Get or create category (if not provided)
|
||||
if not category and recipe_data.get("category"):
|
||||
category, _ = FoodCategory.objects.get_or_create(
|
||||
name=recipe_data["category"]
|
||||
)
|
||||
|
||||
# Create Food instance
|
||||
food = cls.objects.create(
|
||||
title=recipe_data.get("title"),
|
||||
source_url=url,
|
||||
source_site=recipe_data.get("site"),
|
||||
ingredients=json.dumps(recipe_data.get("ingredients", [])),
|
||||
instructions=json.dumps(recipe_data.get("instructions", [])),
|
||||
prep_time_minutes=recipe_data.get("prep_time"),
|
||||
cook_time_minutes=recipe_data.get("cook_time"),
|
||||
total_time_minutes=recipe_data.get("total_time"),
|
||||
servings=servings,
|
||||
yield_text=recipe_data.get("yields"),
|
||||
# cuisine=recipe_data.get("cuisine"),
|
||||
# course=recipe_data.get("course"),
|
||||
calories=int(nutrition.get("calories", 0)) if nutrition else None,
|
||||
protein=nutrition.get("protein") if nutrition else None,
|
||||
fat=nutrition.get("fat") if nutrition else None,
|
||||
carbohydrates=(
|
||||
nutrition.get("carbohydrates") if nutrition else None
|
||||
),
|
||||
fiber=nutrition.get("fiber") if nutrition else None,
|
||||
category=category,
|
||||
)
|
||||
|
||||
# Add tags via django-taggit
|
||||
if tags:
|
||||
food.genre.add(*tags)
|
||||
|
||||
# Save image if downloaded
|
||||
if recipe_image:
|
||||
food.recipe_image.save(recipe_image.name, recipe_image, save=True)
|
||||
|
||||
if "allrecipes.com" in url:
|
||||
food.allrecipe_id = url.split("/recipe/")[-1].split("/")[0]
|
||||
food.save()
|
||||
|
||||
return food, True
|
||||
|
||||
@classmethod
|
||||
def find_or_create_from_ingredients(
|
||||
cls,
|
||||
ingredients: list[str],
|
||||
title: str,
|
||||
servings: int = 1,
|
||||
category=None,
|
||||
) -> Tuple["Food", bool]:
|
||||
"""
|
||||
Create Food from ingredient list (no URL).
|
||||
Calculates nutrition using USDA API.
|
||||
"""
|
||||
# Check if similar recipe exists
|
||||
existing = cls.objects.filter(description__icontains=title).first()
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
calculator = NutritionCalculator()
|
||||
nutrition = calculator.calculate_nutrition(ingredients, servings)
|
||||
|
||||
food = cls.objects.create(
|
||||
description=title,
|
||||
ingredients=json.dumps(ingredients),
|
||||
servings=servings,
|
||||
calories=int(nutrition.get("calories", 0)) if nutrition else None,
|
||||
protein=nutrition.get("protein") if nutrition else None,
|
||||
fat=nutrition.get("fat") if nutrition else None,
|
||||
carbohydrates=(
|
||||
nutrition.get("carbohydrates") if nutrition else None
|
||||
),
|
||||
fiber=nutrition.get("fiber") if nutrition else None,
|
||||
category=category,
|
||||
)
|
||||
|
||||
return food, True
|
||||
|
||||
def refresh_nutrition(self) -> bool:
|
||||
"""Recalculate nutrition from ingredients (useful if USDA data updated)."""
|
||||
if not self.ingredients:
|
||||
return False
|
||||
|
||||
try:
|
||||
ingredients = json.loads(self.ingredients)
|
||||
calculator = NutritionCalculator()
|
||||
nutrition = calculator.calculate_nutrition(
|
||||
ingredients, self.servings or 1
|
||||
)
|
||||
|
||||
self.calories = int(nutrition.get("calories", 0))
|
||||
self.protein = nutrition.get("protein")
|
||||
self.fat = nutrition.get("fat")
|
||||
self.carbohydrates = nutrition.get("carbohydrates")
|
||||
self.fiber = nutrition.get("fiber")
|
||||
self.save(
|
||||
update_fields=[
|
||||
"calories",
|
||||
"protein",
|
||||
"fat",
|
||||
"carbohydrates",
|
||||
"fiber",
|
||||
]
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
|
||||
190
vrobbler/apps/foods/sources/rscraper.py
Normal file
190
vrobbler/apps/foods/sources/rscraper.py
Normal file
@ -0,0 +1,190 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from django.core.files.base import ContentFile
|
||||
from foods.constants import EXCLUDED_FOOD_DOMAINS
|
||||
from recipe_scrapers import scrape_html
|
||||
from recipe_scrapers._exceptions import (
|
||||
NoSchemaFoundInWildMode,
|
||||
SchemaOrgException,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecipeScraperService:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{"User-Agent": "Mozilla/5.0 (compatible; Vrobbler/0.3)"}
|
||||
)
|
||||
|
||||
def scrape(self, html: str, org_url: str) -> Dict:
|
||||
"""Scrape recipe data from HTML.
|
||||
|
||||
Args:
|
||||
html: The HTML content of the page
|
||||
org_url: The original URL the HTML was fetched from
|
||||
"""
|
||||
try:
|
||||
scraper = scrape_html(html, org_url=org_url, supported_only=False)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to scrape {org_url}: {str(e)}")
|
||||
|
||||
data_dict = {
|
||||
"title": scraper.title(),
|
||||
"ingredients": self._safe_call(scraper, "ingredients", []),
|
||||
"instructions": self._safe_call(scraper, "instructions_list", []),
|
||||
"yields": self._safe_call(scraper, "yields"),
|
||||
"nutrition": self._safe_call(scraper, "nutrition"),
|
||||
"course": self._safe_call(scraper, "course"),
|
||||
"dietary": self._safe_call(scraper, "dietary", []),
|
||||
"prep_time": self._safe_call(scraper, "prep_time"),
|
||||
"total_time": self._safe_call(scraper, "total_time"),
|
||||
"image": self._safe_call(scraper, "image"),
|
||||
"cook_time": self._safe_call(scraper, "cook_time"),
|
||||
"cuisine": self._safe_call(scraper, "cuisine"),
|
||||
"site": self._safe_call(scraper, "host"),
|
||||
"url": org_url,
|
||||
"category": self._safe_call(scraper, "category"),
|
||||
"keywords": self._safe_call(scraper, "keywords"),
|
||||
}
|
||||
|
||||
return data_dict
|
||||
|
||||
def scrape_url(self, url: str):
|
||||
response = self.session.get(url)
|
||||
if response.status_code != 200:
|
||||
raise Exception("Recipe website returned non 200 response")
|
||||
return self.scrape(response.text, org_url=url)
|
||||
|
||||
@classmethod
|
||||
def is_recipe(self, html: str, org_url: str) -> bool:
|
||||
"""Check if HTML contains a recipe.
|
||||
|
||||
Args:
|
||||
html: The HTML content to check
|
||||
org_url: The original URL the HTML was fetched from
|
||||
"""
|
||||
try:
|
||||
scraper = scrape_html(html, org_url=org_url, supported_only=False)
|
||||
return scraper.schema is not None
|
||||
except NoSchemaFoundInWildMode:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def is_recipe_url(cls, url: str) -> bool:
|
||||
"""Check if a URL points to a recipe by fetching and checking the HTML."""
|
||||
domain = urlparse(url).netloc
|
||||
if domain in EXCLUDED_FOOD_DOMAINS and "food" not in url:
|
||||
logger.info("[food] domain found in EXCLUDED_FOOD_DOMAINS")
|
||||
return False
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=10,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; Vrobbler/0.3)"
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.debug("Recipe website returned non 200 response")
|
||||
return False
|
||||
return cls.is_recipe(response.text, url)
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def _safe_call(self, scraper, method, default=None):
|
||||
"""
|
||||
Safely call a scraper method, returning default on error.
|
||||
Handles both missing methods (AttributeError) and methods that return None.
|
||||
"""
|
||||
try:
|
||||
# Check if method exists before calling
|
||||
if not hasattr(scraper, method):
|
||||
return default
|
||||
|
||||
result = getattr(scraper, method)()
|
||||
return result if result else default
|
||||
except (
|
||||
AttributeError,
|
||||
TypeError,
|
||||
KeyError,
|
||||
ValueError,
|
||||
SchemaOrgException,
|
||||
):
|
||||
# AttributeError: method doesn't exist on this scraper class
|
||||
# TypeError: method exists but can't be called
|
||||
# KeyError: method exists but data structure is unexpected
|
||||
return default
|
||||
|
||||
def download_image(self, image_url: str) -> Optional[ContentFile]:
|
||||
"""Download recipe image and return as Django ContentFile."""
|
||||
if not image_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
response = self.session.get(image_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
filename = image_url.split("/")[-1].split("?")[0]
|
||||
if not filename.endswith((".jpg", ".jpeg", ".png", ".webp")):
|
||||
filename = "recipe_image.jpg"
|
||||
|
||||
return ContentFile(response.content, name=filename)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def parse_servings(self, yield_text: str) -> Optional[int]:
|
||||
"""Extract number of servings from yield text."""
|
||||
import re
|
||||
|
||||
if not yield_text:
|
||||
return None
|
||||
|
||||
match = re.search(r"(\d+)", yield_text)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
def extract_tags(self, recipe_data: Dict) -> List[str]:
|
||||
"""Extract all tags from recipe metadata."""
|
||||
tags = set()
|
||||
|
||||
# Add cuisine
|
||||
if recipe_data.get("cuisine"):
|
||||
cuisine = recipe_data["cuisine"]
|
||||
if isinstance(cuisine, str):
|
||||
tags.add(cuisine.strip())
|
||||
elif isinstance(cuisine, list):
|
||||
tags.update([c.strip() for c in cuisine if c])
|
||||
|
||||
# Add dietary
|
||||
if recipe_data.get("dietary"):
|
||||
dietary = recipe_data["dietary"]
|
||||
if isinstance(dietary, str):
|
||||
tags.add(dietary.strip())
|
||||
elif isinstance(dietary, list):
|
||||
tags.update([d.strip() for d in dietary if d])
|
||||
|
||||
# Add course
|
||||
if recipe_data.get("course"):
|
||||
course = recipe_data["course"]
|
||||
if isinstance(course, str):
|
||||
tags.add(course.strip())
|
||||
elif isinstance(course, list):
|
||||
tags.update([c.strip() for c in course if c])
|
||||
|
||||
# Add keywords
|
||||
keywords = recipe_data.get("keywords")
|
||||
if keywords:
|
||||
if isinstance(keywords, str):
|
||||
tags.update(
|
||||
[k.strip() for k in keywords.split(",") if k.strip()]
|
||||
)
|
||||
elif isinstance(keywords, list):
|
||||
tags.update([k.strip() for k in keywords if k.strip()])
|
||||
|
||||
return list(tags)
|
||||
577
vrobbler/apps/foods/sources/usda.py
Normal file
577
vrobbler/apps/foods/sources/usda.py
Normal file
@ -0,0 +1,577 @@
|
||||
from django.conf import settings
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
from functools import lru_cache
|
||||
from typing import List, Dict
|
||||
import re
|
||||
from fractions import Fraction
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class USDAFoodAPI:
|
||||
BASE_URL = "https://api.nal.usda.gov/fdc/v1"
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key or getattr(settings, "USDA_API_KEY", None)
|
||||
if not self.api_key:
|
||||
raise ValueError(
|
||||
"USDA API key not found. Set USDA_API_KEY in your Django settings."
|
||||
)
|
||||
self.session = requests.Session()
|
||||
|
||||
def _get_base_params(self) -> Dict:
|
||||
"""Return base parameters including the API key."""
|
||||
return {"api_key": self.api_key}
|
||||
|
||||
@lru_cache(maxsize=100)
|
||||
def search_foods(
|
||||
self, query: str, page_size: int = 50, page_number: int = 1
|
||||
) -> List[Dict]:
|
||||
"""Search for food items by keyword."""
|
||||
url = f"{self.BASE_URL}/foods/search"
|
||||
params = {
|
||||
**self._get_base_params(),
|
||||
"query": query,
|
||||
"pageSize": page_size,
|
||||
"pageNumber": page_number,
|
||||
"includeNutrientCodes": "203,204,205,208,269",
|
||||
}
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("foods", [])
|
||||
|
||||
def get_food_details(self, fdc_id: int) -> Dict:
|
||||
"""Retrieve detailed nutrient data for a specific food item."""
|
||||
url = f"{self.BASE_URL}/food/{fdc_id}"
|
||||
params = {
|
||||
**self._get_base_params(),
|
||||
"nutrients": [
|
||||
203,
|
||||
204,
|
||||
205,
|
||||
208,
|
||||
269,
|
||||
], # Protein, Fat, Carbs, Calories, Fiber
|
||||
}
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_multiple_foods(self, fdc_ids: List[int]) -> List[Dict]:
|
||||
"""Retrieve details for multiple food items."""
|
||||
url = f"{self.BASE_URL}/foods"
|
||||
params = {
|
||||
**self._get_base_params(),
|
||||
"fdcIds": fdc_ids,
|
||||
"nutrients": ["203", "204", "205", "208", "269"],
|
||||
}
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json().get("foods", [])
|
||||
|
||||
def extract_nutrients(self, food_data: Dict) -> Dict:
|
||||
"""Extract key nutrients into a clean dictionary."""
|
||||
nutrients = {
|
||||
"protein": 0,
|
||||
"fat": 0,
|
||||
"carbohydrates": 0,
|
||||
"calories": 0,
|
||||
"fiber": 0,
|
||||
"sugar": 0,
|
||||
"sodium": 0,
|
||||
}
|
||||
nutrient_map = {
|
||||
"203": "protein",
|
||||
"204": "fat",
|
||||
"205": "carbohydrates",
|
||||
"208": "calories",
|
||||
"269": "fiber",
|
||||
"2000": "sodium",
|
||||
"269": "sugar",
|
||||
}
|
||||
food_nutrients = food_data.get("foodNutrients", [])
|
||||
|
||||
if not food_nutrients:
|
||||
logger.warning(
|
||||
f"No nutrients found for food: {food_data.get('description', 'unknown')}"
|
||||
)
|
||||
return nutrients
|
||||
|
||||
for nutrient in food_nutrients:
|
||||
nutrient_id = None
|
||||
value = None
|
||||
|
||||
if isinstance(nutrient, dict):
|
||||
if "nutrientNumber" in nutrient:
|
||||
nutrient_id = str(nutrient.get("nutrientNumber", ""))
|
||||
value = nutrient.get("value")
|
||||
elif "nutrient" in nutrient and isinstance(
|
||||
nutrient["nutrient"], dict
|
||||
):
|
||||
nutrient_id = str(
|
||||
nutrient.get("nutrient", {}).get("id", "")
|
||||
)
|
||||
value = nutrient.get("value")
|
||||
elif "number" in nutrient:
|
||||
nutrient_id = str(nutrient.get("number", ""))
|
||||
value = nutrient.get("value")
|
||||
|
||||
if (
|
||||
nutrient_id
|
||||
and nutrient_id in nutrient_map
|
||||
and value is not None
|
||||
):
|
||||
key = nutrient_map[nutrient_id]
|
||||
try:
|
||||
nutrients[key] = float(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return nutrients
|
||||
|
||||
def get_food_summary(self, fdc_id: int) -> Dict:
|
||||
"""Return a simplified summary of a food item."""
|
||||
details = self.get_food_details(fdc_id)
|
||||
return {
|
||||
"fdcId": details.get("fdcId"),
|
||||
"description": details.get("description"),
|
||||
"dataType": details.get("dataType"),
|
||||
"brandOwner": details.get("brandOwner"),
|
||||
"servingSize": details.get("servingSize"),
|
||||
"servingSizeUnit": details.get("servingSizeUnit"),
|
||||
"nutrients": self.extract_nutrients(details),
|
||||
}
|
||||
|
||||
|
||||
class NutritionCalculator:
|
||||
"""Calculate recipe nutrition from ingredients using USDA API."""
|
||||
|
||||
# Common units to strip from ingredient names
|
||||
UNITS = [
|
||||
"cup",
|
||||
"cups",
|
||||
"c",
|
||||
"tablespoon",
|
||||
"tablespoons",
|
||||
"tbsp",
|
||||
"tb",
|
||||
"t",
|
||||
"teaspoon",
|
||||
"teaspoons",
|
||||
"tsp",
|
||||
"ts",
|
||||
"ounce",
|
||||
"ounces",
|
||||
"oz",
|
||||
"fluid ounce",
|
||||
"fluid ounces",
|
||||
"fl oz",
|
||||
"pound",
|
||||
"pounds",
|
||||
"lb",
|
||||
"lbs",
|
||||
"gram",
|
||||
"grams",
|
||||
"g",
|
||||
"kilogram",
|
||||
"kilograms",
|
||||
"kg",
|
||||
"milliliter",
|
||||
"milliliters",
|
||||
"ml",
|
||||
"liter",
|
||||
"liters",
|
||||
"l",
|
||||
"piece",
|
||||
"pieces",
|
||||
"whole",
|
||||
"large",
|
||||
"medium",
|
||||
"small",
|
||||
"can",
|
||||
"cans",
|
||||
"bottle",
|
||||
"bottles",
|
||||
"jar",
|
||||
"jars",
|
||||
"package",
|
||||
"packages",
|
||||
"pkg",
|
||||
"box",
|
||||
"boxes",
|
||||
"slice",
|
||||
"slices",
|
||||
"clove",
|
||||
"cloves",
|
||||
"stick",
|
||||
"sticks",
|
||||
"pinch",
|
||||
"dash",
|
||||
"handful",
|
||||
"bunch",
|
||||
"sprig",
|
||||
"sprigs",
|
||||
]
|
||||
|
||||
# Common modifiers to strip (don't affect nutrition much)
|
||||
MODIFIERS = [
|
||||
"all-purpose",
|
||||
"all purpose",
|
||||
"ap",
|
||||
"bread",
|
||||
"cake",
|
||||
"self-rising",
|
||||
"self rising",
|
||||
"granulated",
|
||||
"powdered",
|
||||
"confectioners",
|
||||
"brown",
|
||||
"white",
|
||||
"raw",
|
||||
"extra virgin",
|
||||
"virgin",
|
||||
"light",
|
||||
"dark",
|
||||
"heavy",
|
||||
"whipping",
|
||||
"fresh",
|
||||
"dried",
|
||||
"frozen",
|
||||
"canned",
|
||||
"jarred",
|
||||
"bottled",
|
||||
"chopped",
|
||||
"diced",
|
||||
"minced",
|
||||
"sliced",
|
||||
"grated",
|
||||
"shredded",
|
||||
"crushed",
|
||||
"melted",
|
||||
"softened",
|
||||
"room temperature",
|
||||
"cold",
|
||||
"warm",
|
||||
"hot",
|
||||
"packed",
|
||||
"sifted",
|
||||
"sieved",
|
||||
"ground",
|
||||
"whole",
|
||||
"halved",
|
||||
"quartered",
|
||||
"peeled",
|
||||
"unpeeled",
|
||||
"seeded",
|
||||
"pitted",
|
||||
"cored",
|
||||
"trimmed",
|
||||
"optional",
|
||||
"to taste",
|
||||
"as needed",
|
||||
"for garnish",
|
||||
"for serving",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.usda = USDAFoodAPI()
|
||||
self._unit_pattern = self._build_unit_pattern()
|
||||
|
||||
def _build_unit_pattern(self) -> re.Pattern:
|
||||
"""Build regex pattern for matching units."""
|
||||
# Sort by length (longest first) to match "tablespoon" before "tbsp"
|
||||
sorted_units = sorted(self.UNITS, key=len, reverse=True)
|
||||
unit_str = "|".join(re.escape(u) for u in sorted_units)
|
||||
return re.compile(rf"\b({unit_str})\b", re.IGNORECASE)
|
||||
|
||||
def parse_ingredient(self, ingredient_text: str) -> Dict:
|
||||
"""
|
||||
Parse ingredient string into quantity, unit, and clean ingredient name.
|
||||
|
||||
Examples:
|
||||
"2 cups all-purpose flour" → {qty: 2, unit: 'cups', name: 'flour'}
|
||||
"1 1/2 tsp salt" → {qty: 1.5, unit: 'tsp', name: 'salt'}
|
||||
"1/4 cup olive oil" → {qty: 0.25, unit: 'cup', name: 'olive oil'}
|
||||
"3 large eggs" → {qty: 3, unit: 'large', name: 'eggs'}
|
||||
"""
|
||||
text = ingredient_text.strip()
|
||||
|
||||
# Parse quantity (handles whole numbers, fractions, and mixed numbers)
|
||||
quantity, remaining = self._parse_quantity(text)
|
||||
|
||||
# Parse unit from remaining text
|
||||
unit, remaining = self._parse_unit(remaining)
|
||||
|
||||
# Clean the ingredient name
|
||||
ingredient_name = self._clean_ingredient_name(remaining)
|
||||
|
||||
return {
|
||||
"quantity": quantity,
|
||||
"unit": unit,
|
||||
"ingredient": ingredient_name,
|
||||
"original": text,
|
||||
}
|
||||
|
||||
def _parse_quantity(self, text: str) -> tuple:
|
||||
"""
|
||||
Extract quantity from start of ingredient text.
|
||||
Handles: "2", "1/2", "1 1/2", "0.5", etc.
|
||||
"""
|
||||
text = text.strip()
|
||||
|
||||
# Pattern for mixed numbers: "1 1/2", "2 3/4"
|
||||
mixed_pattern = r"^(\d+)\s+(\d+)/(\d+)"
|
||||
mixed_match = re.match(mixed_pattern, text)
|
||||
if mixed_match:
|
||||
whole = int(mixed_match.group(1))
|
||||
numerator = int(mixed_match.group(2))
|
||||
denominator = int(mixed_match.group(3))
|
||||
quantity = whole + (numerator / denominator)
|
||||
remaining = text[mixed_match.end() :].strip()
|
||||
return quantity, remaining
|
||||
|
||||
# Pattern for simple fractions: "1/2", "3/4"
|
||||
fraction_pattern = r"^(\d+)/(\d+)"
|
||||
fraction_match = re.match(fraction_pattern, text)
|
||||
if fraction_match:
|
||||
numerator = int(fraction_match.group(1))
|
||||
denominator = int(fraction_match.group(2))
|
||||
quantity = numerator / denominator
|
||||
remaining = text[fraction_match.end() :].strip()
|
||||
return quantity, remaining
|
||||
|
||||
# Pattern for decimals: "0.5", "1.5"
|
||||
decimal_pattern = r"^(\d+\.?\d*)"
|
||||
decimal_match = re.match(decimal_pattern, text)
|
||||
if decimal_match:
|
||||
quantity = float(decimal_match.group(1))
|
||||
remaining = text[decimal_match.end() :].strip()
|
||||
return quantity, remaining
|
||||
|
||||
# No quantity found
|
||||
return 1, text
|
||||
|
||||
def _parse_unit(self, text: str) -> tuple:
|
||||
"""Extract unit from text."""
|
||||
text = text.strip()
|
||||
|
||||
match = self._unit_pattern.match(text)
|
||||
if match:
|
||||
unit = match.group(1).lower()
|
||||
remaining = text[match.end() :].strip()
|
||||
return unit, remaining
|
||||
|
||||
return None, text
|
||||
|
||||
def _clean_ingredient_name(self, text: str) -> str:
|
||||
"""
|
||||
Clean ingredient name for better USDA matching.
|
||||
Removes modifiers, extra whitespace, and normalizes text.
|
||||
"""
|
||||
text = text.strip()
|
||||
|
||||
# Remove parenthetical notes: "flour (sifted)" → "flour"
|
||||
text = re.sub(r"\s*\([^)]*\)", "", text)
|
||||
|
||||
# Remove common modifiers
|
||||
for modifier in sorted(self.MODIFIERS, key=len, reverse=True):
|
||||
# Use word boundaries to avoid partial matches
|
||||
pattern = rf"\b{re.escape(modifier)}\b"
|
||||
text = re.sub(pattern, "", text, flags=re.IGNORECASE)
|
||||
|
||||
# Remove extra whitespace
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
|
||||
# Remove trailing punctuation
|
||||
text = text.strip(" ,;:.")
|
||||
|
||||
# Remove leading "of" (e.g., "cup of flour" → "flour")
|
||||
text = re.sub(r"^of\s+", "", text, flags=re.IGNORECASE)
|
||||
|
||||
return text.strip()
|
||||
|
||||
def _find_usda_match(self, ingredient_name: str) -> Optional[Dict]:
|
||||
"""
|
||||
Find best USDA match for an ingredient name.
|
||||
Tries multiple search strategies for better matching.
|
||||
"""
|
||||
strategies = [
|
||||
# Strategy 1: Direct search
|
||||
ingredient_name,
|
||||
# Strategy 2: Singular form (remove trailing 's')
|
||||
(
|
||||
ingredient_name.rstrip("s")
|
||||
if ingredient_name.endswith("s")
|
||||
else None
|
||||
),
|
||||
# Strategy 3: Remove common suffixes
|
||||
re.sub(
|
||||
r"\s+(fresh|dried|ground|chopped|sliced)$",
|
||||
"",
|
||||
ingredient_name,
|
||||
flags=re.IGNORECASE,
|
||||
),
|
||||
# Strategy 4: Just the last word (sometimes works for simple ingredients)
|
||||
(
|
||||
ingredient_name.split()[-1]
|
||||
if len(ingredient_name.split()) > 1
|
||||
else None
|
||||
),
|
||||
]
|
||||
|
||||
for query in strategies:
|
||||
if not query or len(query) < 2:
|
||||
continue
|
||||
|
||||
try:
|
||||
results = self.usda.search_foods(query, page_size=5)
|
||||
if results:
|
||||
logger.debug(
|
||||
f"Found {query}: {results[0].get('description')}"
|
||||
)
|
||||
return results[0]
|
||||
except Exception as e:
|
||||
logger.warning(f"USDA search failed for '{query}': {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _convert_to_grams(
|
||||
self, quantity: float, unit: str, ingredient_name: str
|
||||
) -> float:
|
||||
"""
|
||||
Convert quantity to grams for more accurate nutrition calculation.
|
||||
This is simplified - production code should use a proper conversion table.
|
||||
"""
|
||||
# Base conversions (approximate)
|
||||
volume_to_grams = {
|
||||
"cup": 240,
|
||||
"cups": 240,
|
||||
"tablespoon": 15,
|
||||
"tablespoons": 15,
|
||||
"tbsp": 15,
|
||||
"teaspoon": 5,
|
||||
"teaspoons": 5,
|
||||
"tsp": 5,
|
||||
"ounce": 28,
|
||||
"ounces": 28,
|
||||
"oz": 28,
|
||||
"pound": 454,
|
||||
"pounds": 454,
|
||||
"lb": 454,
|
||||
"lbs": 454,
|
||||
"gram": 1,
|
||||
"grams": 1,
|
||||
"g": 1,
|
||||
}
|
||||
|
||||
# Get base weight in grams
|
||||
base_grams = volume_to_grams.get(
|
||||
unit, 100
|
||||
) # Default to 100g if unknown
|
||||
|
||||
# Adjust for ingredient type (very simplified)
|
||||
if any(
|
||||
word in ingredient_name.lower()
|
||||
for word in ["flour", "sugar", "rice", "grain"]
|
||||
):
|
||||
base_grams = volume_to_grams.get(unit, 125) # Dry ingredients
|
||||
elif any(
|
||||
word in ingredient_name.lower()
|
||||
for word in ["oil", "butter", "fat"]
|
||||
):
|
||||
base_grams = volume_to_grams.get(unit, 220) # Dense liquids
|
||||
elif any(
|
||||
word in ingredient_name.lower()
|
||||
for word in ["milk", "water", "broth"]
|
||||
):
|
||||
base_grams = volume_to_grams.get(unit, 240) # Liquids
|
||||
|
||||
return quantity * base_grams
|
||||
|
||||
def calculate_nutrition(
|
||||
self, ingredients: List[str], servings: int = 1
|
||||
) -> Dict:
|
||||
"""
|
||||
Calculate total nutrition for a recipe from ingredient list.
|
||||
Returns nutrition per serving.
|
||||
"""
|
||||
totals = {
|
||||
"calories": 0,
|
||||
"protein": 0,
|
||||
"fat": 0,
|
||||
"carbohydrates": 0,
|
||||
"fiber": 0,
|
||||
"sugar": 0,
|
||||
"sodium": 0,
|
||||
}
|
||||
|
||||
matched_count = 0
|
||||
unmatched = []
|
||||
|
||||
for ingredient in ingredients:
|
||||
parsed = self.parse_ingredient(ingredient)
|
||||
|
||||
# Find USDA match
|
||||
match = self._find_usda_match(
|
||||
" ".join(
|
||||
[
|
||||
str(parsed["quantity"]),
|
||||
str(parsed["unit"]),
|
||||
str(parsed["ingredient"]),
|
||||
]
|
||||
)
|
||||
)
|
||||
if not match:
|
||||
match = self._find_usda_match(parsed["ingredient"])
|
||||
|
||||
if match:
|
||||
try:
|
||||
nutrients = self.usda.extract_nutrients(match)
|
||||
# Convert to grams for more accurate calculation
|
||||
grams = self._convert_to_grams(
|
||||
parsed["quantity"],
|
||||
parsed["unit"],
|
||||
parsed["ingredient"],
|
||||
)
|
||||
|
||||
# USDA data is typically per 100g, so scale accordingly
|
||||
multiplier = grams / 100
|
||||
|
||||
totals["calories"] += (
|
||||
nutrients.get("calories", 0) * multiplier
|
||||
)
|
||||
totals["protein"] += (
|
||||
nutrients.get("protein", 0) * multiplier
|
||||
)
|
||||
totals["fat"] += nutrients.get("fat", 0) * multiplier
|
||||
totals["carbohydrates"] += (
|
||||
nutrients.get("carbohydrates", 0) * multiplier
|
||||
)
|
||||
totals["fiber"] += nutrients.get("fiber", 0) * multiplier
|
||||
totals["sugar"] += nutrients.get("sugar", 0) * multiplier
|
||||
totals["sodium"] += nutrients.get("sodium", 0) * multiplier
|
||||
|
||||
matched_count += 1
|
||||
|
||||
except Exception:
|
||||
unmatched.append(parsed["original"])
|
||||
else:
|
||||
unmatched.append(parsed["original"])
|
||||
|
||||
# Divide by servings for per-serving nutrition
|
||||
if servings > 0:
|
||||
for key in totals:
|
||||
totals[key] = round(totals[key] / servings, 2)
|
||||
|
||||
# Add metadata
|
||||
totals["_matched"] = matched_count
|
||||
totals["_total"] = len(ingredients)
|
||||
totals["_unmatched"] = unmatched
|
||||
|
||||
return totals
|
||||
8
vrobbler/apps/lifeevents/api/serializers.py
Normal file
8
vrobbler/apps/lifeevents/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from lifeevents.models import LifeEvent
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class LifeEventSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = LifeEvent
|
||||
fields = "__all__"
|
||||
10
vrobbler/apps/lifeevents/api/views.py
Normal file
10
vrobbler/apps/lifeevents/api/views.py
Normal file
@ -0,0 +1,10 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from lifeevents.api import serializers
|
||||
from lifeevents import models
|
||||
|
||||
|
||||
class LifeEventViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.LifeEvent.objects.all().order_by("-created")
|
||||
serializer_class = serializers.LifeEventSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lifeevents', '0002_alter_lifeevent_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='lifeevent',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='lifeevent',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lifeevent',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,12 +1,18 @@
|
||||
from dataclasses import dataclass
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from scrobbles.dataclasses import LifeEventLogData
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LifeEventLogData(BaseLogData, WithPeopleLogData):
|
||||
pass
|
||||
|
||||
|
||||
class LifeEvent(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
from django.views import generic
|
||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||
from lifeevents.models import LifeEvent
|
||||
|
||||
|
||||
class LifeEventListView(generic.ListView):
|
||||
class LifeEventListView(ScrobbleableListView):
|
||||
model = LifeEvent
|
||||
paginate_by = 20
|
||||
|
||||
|
||||
class LifeEventDetailView(generic.DetailView):
|
||||
class LifeEventDetailView(ScrobbleableDetailView):
|
||||
model = LifeEvent
|
||||
slug_field = "uuid"
|
||||
|
||||
8
vrobbler/apps/locations/api/serializers.py
Normal file
8
vrobbler/apps/locations/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from locations import models
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class GeoLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.GeoLocation
|
||||
fields = "__all__"
|
||||
10
vrobbler/apps/locations/api/views.py
Normal file
10
vrobbler/apps/locations/api/views.py
Normal file
@ -0,0 +1,10 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from locations.api import serializers
|
||||
from locations import models
|
||||
|
||||
|
||||
class GeoLocationViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.GeoLocation.objects.all().order_by("-created")
|
||||
serializer_class = serializers.GeoLocationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -3,4 +3,3 @@ from django.apps import AppConfig
|
||||
|
||||
class LocationConfig(AppConfig):
|
||||
name = "locations"
|
||||
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('locations', '0007_alter_geolocation_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='geolocation',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='geolocation',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='geolocation',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,13 @@
|
||||
from decimal import Decimal, getcontext
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -18,6 +18,11 @@ GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
|
||||
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoLocationLogData(BaseLogData, WithPeopleLogData):
|
||||
pass
|
||||
|
||||
|
||||
class GeoLocation(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
||||
|
||||
@ -38,9 +43,13 @@ class GeoLocation(ScrobblableMixin):
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"locations:geo_location_detail", kwargs={"slug": self.uuid}
|
||||
"locations:geolocation_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return GeoLocationLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, data_dict: Dict) -> "GeoLocation":
|
||||
"""Given a data dict from GPSLogger, does the heavy lifting of looking up
|
||||
|
||||
@ -8,11 +8,11 @@ urlpatterns = [
|
||||
path(
|
||||
"locations/",
|
||||
views.GeoLocationListView.as_view(),
|
||||
name="geo_locations_list",
|
||||
name="geolocation_list",
|
||||
),
|
||||
path(
|
||||
"locations/<slug:slug>/",
|
||||
views.GeoLocationDetailView.as_view(),
|
||||
name="geo_location_detail",
|
||||
name="geolocation_detail",
|
||||
),
|
||||
]
|
||||
|
||||
@ -9,7 +9,12 @@ class GeoLocationListView(generic.ListView):
|
||||
paginate_by = 75
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(scrobble__user_id=self.request.user.id).order_by("-scrobble__timestamp")
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(scrobble__user_id=self.request.user.id)
|
||||
.order_by("-scrobble__timestamp")
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
|
||||
0
vrobbler/apps/moods/api/__init__.py
Normal file
0
vrobbler/apps/moods/api/__init__.py
Normal file
8
vrobbler/apps/moods/api/serializers.py
Normal file
8
vrobbler/apps/moods/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
from moods.models import Mood
|
||||
|
||||
|
||||
class MoodSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Mood
|
||||
fields = "__all__"
|
||||
9
vrobbler/apps/moods/api/views.py
Normal file
9
vrobbler/apps/moods/api/views.py
Normal file
@ -0,0 +1,9 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from moods.api.serializers import MoodSerializer
|
||||
from moods.models import Mood
|
||||
|
||||
|
||||
class MoodViewSet(viewsets.ModelViewSet):
|
||||
queryset = Mood.objects.all().order_by("-created")
|
||||
serializer_class = MoodSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('moods', '0003_alter_mood_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='mood',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mood',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mood',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,19 +1,25 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoodLogData(BaseLogData):
|
||||
reasons: Optional[str] = None
|
||||
|
||||
|
||||
class Mood(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
image = models.ImageField(upload_to="moods/", **BNULL)
|
||||
@ -36,7 +42,7 @@ class Mood(ScrobblableMixin):
|
||||
return str(self.uuid)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
|
||||
return reverse("moods:mood_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
|
||||
@ -5,10 +5,10 @@ app_name = "moods"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("moods/", views.MoodListView.as_view(), name="mood-list"),
|
||||
path("moods/", views.MoodListView.as_view(), name="mood_list"),
|
||||
path(
|
||||
"moods/<slug:slug>/",
|
||||
views.MoodDetailView.as_view(),
|
||||
name="mood-detail",
|
||||
name="mood_detail",
|
||||
),
|
||||
]
|
||||
|
||||
@ -16,6 +16,7 @@ class AlbumAdmin(admin.ModelAdmin):
|
||||
"theaudiodb_mood",
|
||||
"musicbrainz_id",
|
||||
)
|
||||
raw_id_fields = ("album_artist",)
|
||||
list_filter = (
|
||||
"theaudiodb_score",
|
||||
"theaudiodb_genre",
|
||||
@ -49,13 +50,17 @@ class TrackAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"album",
|
||||
"primary_album",
|
||||
"artist",
|
||||
"musicbrainz_id",
|
||||
)
|
||||
raw_id_fields = ("artist", "albums", "album")
|
||||
list_filter = ("album", "artist")
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
filter_horizontal = [
|
||||
"albums",
|
||||
]
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
@ -16,9 +16,7 @@ def scrobble_counts(user=None):
|
||||
now = now_user_timezone(user.profile)
|
||||
user_filter = Q(user=user)
|
||||
|
||||
start_of_today = datetime.combine(
|
||||
now.date(), datetime.min.time(), now.tzinfo
|
||||
)
|
||||
start_of_today = datetime.combine(now.date(), datetime.min.time(), now.tzinfo)
|
||||
starting_day_of_current_week = now.date() - timedelta(
|
||||
days=now.today().isoweekday() % 7
|
||||
)
|
||||
@ -31,9 +29,7 @@ def scrobble_counts(user=None):
|
||||
media_type=Scrobble.MediaType.TRACK,
|
||||
)
|
||||
data = {}
|
||||
data["today"] = finished_scrobbles_qs.filter(
|
||||
timestamp__gte=start_of_today
|
||||
).count()
|
||||
data["today"] = finished_scrobbles_qs.filter(timestamp__gte=start_of_today).count()
|
||||
data["week"] = finished_scrobbles_qs.filter(
|
||||
timestamp__gte=starting_day_of_current_week
|
||||
).count()
|
||||
@ -47,9 +43,7 @@ def scrobble_counts(user=None):
|
||||
return data
|
||||
|
||||
|
||||
def week_of_scrobbles(
|
||||
user=None, start=None, media: str = "tracks"
|
||||
) -> dict[str, int]:
|
||||
def week_of_scrobbles(user=None, start=None, media: str = "tracks") -> dict[str, int]:
|
||||
|
||||
now = timezone.now()
|
||||
user_filter = Q()
|
||||
@ -89,6 +83,7 @@ def live_charts(
|
||||
media_type: str = "Track",
|
||||
chart_period: str = "all",
|
||||
limit: int = 15,
|
||||
app_label: str = "music",
|
||||
) -> QuerySet:
|
||||
now = timezone.now()
|
||||
tzinfo = now.tzinfo
|
||||
@ -100,13 +95,11 @@ def live_charts(
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
|
||||
start_day_of_week = start_of_today - timedelta(
|
||||
days=now.today().isoweekday() % 7
|
||||
)
|
||||
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
|
||||
start_day_of_month = now.replace(day=1)
|
||||
start_day_of_year = now.replace(month=1, day=1)
|
||||
|
||||
media_model = apps.get_model(app_label="music", model_name=media_type)
|
||||
media_model = apps.get_model(app_label=app_label, model_name=media_type)
|
||||
|
||||
period_queries = {
|
||||
"today": {"scrobble__timestamp__gte": start_of_today},
|
||||
@ -119,17 +112,13 @@ def live_charts(
|
||||
}
|
||||
|
||||
time_filter = Q()
|
||||
completion_filter = Q(
|
||||
scrobble__user=user, scrobble__played_to_completion=True
|
||||
)
|
||||
completion_filter = Q(scrobble__user=user, scrobble__played_to_completion=True)
|
||||
user_filter = Q(scrobble__user=user)
|
||||
count_field = "scrobble"
|
||||
|
||||
if media_type == "Artist":
|
||||
for period, query_dict in period_queries.items():
|
||||
period_queries[period] = {
|
||||
"track__" + k: v for k, v in query_dict.items()
|
||||
}
|
||||
period_queries[period] = {"track__" + k: v for k, v in query_dict.items()}
|
||||
completion_filter = Q(
|
||||
track__scrobble__user=user,
|
||||
track__scrobble__played_to_completion=True,
|
||||
@ -154,3 +143,223 @@ def live_charts(
|
||||
|
||||
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
|
||||
return Scrobble.objects.filter(track__artist=artist_id).count()
|
||||
|
||||
|
||||
def live_tv_charts(
|
||||
user: "User",
|
||||
chart_period: str = "all",
|
||||
limit: int = 15,
|
||||
) -> QuerySet:
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from videos.models import Video, Series
|
||||
|
||||
now = timezone.now()
|
||||
tzinfo = now.tzinfo
|
||||
now = now.date()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
tzinfo = now.tzinfo
|
||||
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
|
||||
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
|
||||
start_day_of_month = now.replace(day=1)
|
||||
start_day_of_year = now.replace(month=1, day=1)
|
||||
|
||||
period_queries = {
|
||||
"today": Q(timestamp__gte=start_of_today),
|
||||
"week": Q(timestamp__gte=start_day_of_week),
|
||||
"last7": Q(timestamp__gte=seven_days_ago),
|
||||
"last30": Q(timestamp__gte=thirty_days_ago),
|
||||
"month": Q(timestamp__gte=start_day_of_month),
|
||||
"year": Q(timestamp__gte=start_day_of_year),
|
||||
"all": Q(),
|
||||
}
|
||||
|
||||
time_filter = period_queries[chart_period]
|
||||
completion_filter = Q(
|
||||
user=user,
|
||||
played_to_completion=True,
|
||||
video__video_type=Video.VideoType.TV_EPISODE,
|
||||
)
|
||||
|
||||
scrobble_counts = (
|
||||
Scrobble.objects.filter(
|
||||
completion_filter,
|
||||
time_filter,
|
||||
video__tv_series=OuterRef("pk"),
|
||||
)
|
||||
.values("video__tv_series")
|
||||
.annotate(c=Count("id"))
|
||||
.values("c")
|
||||
)
|
||||
|
||||
return (
|
||||
Series.objects.annotate(num_scrobbles=Subquery(scrobble_counts))
|
||||
.filter(num_scrobbles__gt=0)
|
||||
.order_by("-num_scrobbles")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def live_youtube_channel_charts(
|
||||
user: "User",
|
||||
chart_period: str = "all",
|
||||
limit: int = 15,
|
||||
) -> QuerySet:
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from videos.models import Video, Channel
|
||||
|
||||
now = timezone.now()
|
||||
tzinfo = now.tzinfo
|
||||
now = now_user_timezone(user.profile)
|
||||
tzinfo = now.tzinfo
|
||||
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
|
||||
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
|
||||
start_day_of_year = now.replace(month=1, day=1)
|
||||
|
||||
period_queries = {
|
||||
"today": Q(timestamp__gte=start_of_today),
|
||||
"week": Q(timestamp__gte=start_day_of_week),
|
||||
"last7": Q(timestamp__gte=seven_days_ago),
|
||||
"last30": Q(timestamp__gte=thirty_days_ago),
|
||||
"year": Q(timestamp__gte=start_day_of_year),
|
||||
"all": Q(),
|
||||
}
|
||||
|
||||
time_filter = period_queries[chart_period]
|
||||
completion_filter = Q(
|
||||
user=user,
|
||||
played_to_completion=True,
|
||||
video__video_type=Video.VideoType.YOUTUBE,
|
||||
)
|
||||
|
||||
scrobble_counts = (
|
||||
Scrobble.objects.filter(
|
||||
completion_filter,
|
||||
time_filter,
|
||||
video__channel=OuterRef("pk"),
|
||||
)
|
||||
.values("video__channel")
|
||||
.annotate(c=Count("id"))
|
||||
.values("c")
|
||||
)
|
||||
|
||||
return (
|
||||
Channel.objects.annotate(num_scrobbles=Subquery(scrobble_counts))
|
||||
.filter(num_scrobbles__gt=0)
|
||||
.order_by("-num_scrobbles")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def batch_live_charts(
|
||||
user: "User",
|
||||
media_type: str = "Track",
|
||||
limit: int = 15,
|
||||
app_label: str = "music",
|
||||
) -> dict[str, list]:
|
||||
from django.db.models import Count, Q
|
||||
|
||||
now = timezone.now()
|
||||
tzinfo = now.tzinfo
|
||||
now = now.date()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
tzinfo = now.tzinfo
|
||||
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
|
||||
start_day_of_year = now.replace(month=1, day=1)
|
||||
|
||||
media_model = apps.get_model(app_label=app_label, model_name=media_type)
|
||||
|
||||
if media_type == "Artist":
|
||||
base_filter = Q(
|
||||
track__scrobble__user=user, track__scrobble__played_to_completion=True
|
||||
)
|
||||
count_field = "track__scrobble"
|
||||
else:
|
||||
base_filter = Q(scrobble__user=user, scrobble__played_to_completion=True)
|
||||
count_field = "scrobble"
|
||||
|
||||
scrobble_filter_prefix = (
|
||||
"track__scrobble__" if media_type == "Artist" else "scrobble__"
|
||||
)
|
||||
|
||||
queryset = media_model.objects.filter(
|
||||
Q(track__scrobble__user=user)
|
||||
if media_type == "Artist"
|
||||
else Q(scrobble__user=user)
|
||||
).annotate(
|
||||
num_scrobbles_today=Count(
|
||||
count_field,
|
||||
filter=Q(
|
||||
**{
|
||||
f"{scrobble_filter_prefix}played_to_completion": True,
|
||||
f"{scrobble_filter_prefix}timestamp__gte": start_of_today,
|
||||
}
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
num_scrobbles_last7=Count(
|
||||
count_field,
|
||||
filter=Q(
|
||||
**{
|
||||
f"{scrobble_filter_prefix}played_to_completion": True,
|
||||
f"{scrobble_filter_prefix}timestamp__gte": seven_days_ago,
|
||||
}
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
num_scrobbles_last30=Count(
|
||||
count_field,
|
||||
filter=Q(
|
||||
**{
|
||||
f"{scrobble_filter_prefix}played_to_completion": True,
|
||||
f"{scrobble_filter_prefix}timestamp__gte": thirty_days_ago,
|
||||
}
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
num_scrobbles_year=Count(
|
||||
count_field,
|
||||
filter=Q(
|
||||
**{
|
||||
f"{scrobble_filter_prefix}played_to_completion": True,
|
||||
f"{scrobble_filter_prefix}timestamp__gte": start_day_of_year,
|
||||
}
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
num_scrobbles=Count(
|
||||
count_field,
|
||||
filter=Q(
|
||||
**{
|
||||
f"{scrobble_filter_prefix}played_to_completion": True,
|
||||
}
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
|
||||
items = list(queryset.order_by("-num_scrobbles")[:limit])
|
||||
for item in items:
|
||||
item.count = item.num_scrobbles
|
||||
|
||||
def filter_by_count_attr(qs, attr_name):
|
||||
return sorted(
|
||||
[item for item in items if getattr(item, attr_name) > 0],
|
||||
key=lambda x: -getattr(x, attr_name),
|
||||
)[:limit]
|
||||
|
||||
return {
|
||||
"today": filter_by_count_attr(items, "num_scrobbles_today"),
|
||||
"last7": filter_by_count_attr(items, "num_scrobbles_last7"),
|
||||
"last30": filter_by_count_attr(items, "num_scrobbles_last30"),
|
||||
"year": filter_by_count_attr(items, "num_scrobbles_year"),
|
||||
"all": items,
|
||||
}
|
||||
|
||||
@ -90,6 +90,7 @@ JELLYFIN_POST_KEYS = {
|
||||
"TAGLINE": "TAGLINE",
|
||||
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
|
||||
"PLAYBACK_POSITION": "PlaybackPosition",
|
||||
"MEDIA_SOURCE_ID": "MediaSourceId",
|
||||
"ARTIST_MB_ID": "Provider_musicbrainzartist",
|
||||
"ALBUM_MB_ID": "Provider_musicbrainzalbum",
|
||||
"RELEASEGROUP_MB_ID": "Provider_musicbrainzreleasegroup",
|
||||
|
||||
@ -1,162 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pylast
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from music.models import Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PYLAST_ERRORS = tuple(
|
||||
getattr(pylast, exc_name)
|
||||
for exc_name in (
|
||||
"ScrobblingError",
|
||||
"NetworkError",
|
||||
"MalformedResponseError",
|
||||
"WSError",
|
||||
)
|
||||
if hasattr(pylast, exc_name)
|
||||
)
|
||||
|
||||
|
||||
class LastFM:
|
||||
def __init__(self, user):
|
||||
try:
|
||||
self.client = pylast.LastFMNetwork(
|
||||
api_key=getattr(settings, "LASTFM_API_KEY"),
|
||||
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
|
||||
username=user.profile.lastfm_username,
|
||||
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}")
|
||||
|
||||
def import_from_lastfm(self, last_processed=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)
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
timezone = settings.TIME_ZONE
|
||||
if self.vrobbler_user.profile:
|
||||
timezone = self.vrobbler_user.profile.timezone
|
||||
|
||||
timestamp = lfm_scrobble.get("timestamp")
|
||||
new_scrobble = Scrobble(
|
||||
user=self.vrobbler_user,
|
||||
timestamp=timestamp,
|
||||
source=source,
|
||||
track=track,
|
||||
timezone=timezone,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.TRACK,
|
||||
)
|
||||
# Vrobbler scrobbles on finish, LastFM scrobbles on start
|
||||
seconds_eariler = timestamp - timedelta(seconds=20)
|
||||
seconds_later = timestamp + timedelta(seconds=20)
|
||||
existing = Scrobble.objects.filter(
|
||||
created__gte=seconds_eariler,
|
||||
created__lte=seconds_later,
|
||||
track=track,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble {new_scrobble}")
|
||||
continue
|
||||
logger.debug(f"Queued scrobble {new_scrobble} for creation")
|
||||
new_scrobbles.append(new_scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
# TODO Add a notification for users that their import is complete
|
||||
logger.info(
|
||||
f"Last.fm import fnished",
|
||||
extra={
|
||||
"scrobbles_created": len(created),
|
||||
"user_id": self.vrobbler_user,
|
||||
"lastfm_user": self.user,
|
||||
},
|
||||
)
|
||||
return created
|
||||
|
||||
def get_last_scrobbles(self, time_from=None, time_to=None):
|
||||
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
|
||||
tracks"""
|
||||
lfm_params = {}
|
||||
scrobbles = []
|
||||
if time_from:
|
||||
lfm_params["time_from"] = int(time_from.timestamp())
|
||||
if time_to:
|
||||
lfm_params["time_to"] = int(time_to.timestamp())
|
||||
|
||||
# if not time_from and not time_to:
|
||||
lfm_params["limit"] = None
|
||||
|
||||
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
|
||||
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
|
||||
|
||||
for scrobble in found_scrobbles:
|
||||
logger.debug(f"Processing {scrobble}")
|
||||
run_time = None
|
||||
mbid = None
|
||||
artist = None
|
||||
|
||||
log_dict = {"scrobble": scrobble}
|
||||
try:
|
||||
run_time = int(scrobble.track.get_duration() / 1000)
|
||||
mbid = scrobble.track.get_mbid()
|
||||
artist = scrobble.track.get_artist().name
|
||||
log_dict["artist"] = artist
|
||||
log_dict["mbid"] = mbid
|
||||
log_dict["run_time"] = run_time
|
||||
except pylast.MalformedResponseError as e:
|
||||
logger.warning(e)
|
||||
except pylast.WSError as e:
|
||||
logger.info(
|
||||
"LastFM barfed trying to get the track for {scrobble.track}",
|
||||
extra=log_dict,
|
||||
)
|
||||
except pylast.NetworkError as e:
|
||||
logger.info(
|
||||
"LastFM barfed trying to get the track for {scrobble.track}",
|
||||
extra=log_dict,
|
||||
)
|
||||
|
||||
if not artist:
|
||||
logger.info(
|
||||
f"Silly LastFM, no artist found for scrobble",
|
||||
extra=log_dict,
|
||||
)
|
||||
continue
|
||||
|
||||
# TODO figure out if this will actually work
|
||||
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
|
||||
timestamp = datetime.utcfromtimestamp(
|
||||
int(scrobble.timestamp)
|
||||
).replace(tzinfo=pytz.utc)
|
||||
|
||||
logger.info(
|
||||
f"Scrobble appended to list for bulk create", extra=log_dict
|
||||
)
|
||||
scrobbles.append(
|
||||
{
|
||||
"artist": artist,
|
||||
"album": scrobble.album,
|
||||
"title": scrobble.track.title,
|
||||
"mbid": mbid,
|
||||
"run_time_seconds": run_time,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
return scrobbles
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user