Compare commits
430 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 6d194d227e | |||
| ed217cbad2 | |||
| 3cca30dc70 | |||
| d3a15d5e7b | |||
| deeaa0af4b | |||
| 159459e1b9 | |||
| 89b6d8de06 | |||
| 3c725de2ac | |||
| dd71bdd38c | |||
| d066a98282 | |||
| 73bc4a1cd1 | |||
| 79d58e6390 | |||
| 69e9caf477 | |||
| 776e839ca4 | |||
| 97792898df | |||
| 9986163d20 | |||
| d1229585a1 | |||
| b8c5c3f3e9 | |||
| 6d9e237f9b | |||
| e65a2d300d | |||
| f45541de6d | |||
| 391f0cc335 | |||
| 4b5281bdd8 | |||
| 484be0a64e | |||
| 257e6899d3 | |||
| 4767cc7e52 | |||
| bcc3f46806 | |||
| 6c461ed55f | |||
| 28cf57c6dd | |||
| 0bb874f1db | |||
| 27f50baf5d | |||
| 499c3d6859 | |||
| b0e9f13e11 | |||
| b2ee79b3ea | |||
| 3ddd3b1684 | |||
| 4f8a359ab9 | |||
| 48114aee5e | |||
| 0cc87a2dbe | |||
| 23d3e19db9 | |||
| 29f5e2b940 | |||
| 3208a32ffe | |||
| 99da9b62bf | |||
| 36048d9a0a | |||
| 16091c9053 | |||
| eec00ce658 | |||
| e02010e409 | |||
| 498712e531 | |||
| 14e4432495 | |||
| 676c40176c | |||
| 0a50bca622 | |||
| ac9fc315b1 | |||
| 50d1a4a2bd | |||
| 444562235f | |||
| 760575e41d | |||
| 42699f84d2 | |||
| b660e47bc2 | |||
| 06b4ba8bcc | |||
| 1f67207f81 | |||
| baa8dbee46 | |||
| 3f9cdcac65 | |||
| 71874510a4 | |||
| 8c600d6b4b | |||
| e95b6f50dc | |||
| 93c16d80ec | |||
| b03da9ab37 | |||
| 09ca05cb4f | |||
| d6e02d241c | |||
| 8dd94e2fc4 | |||
| 9e3f714c61 | |||
| e2b0decd83 | |||
| e08db5e3ad | |||
| 1460b9ba77 | |||
| a41e0ffa5d | |||
| c51c75b6a6 | |||
| 041435bc93 | |||
| 15f27b73a5 | |||
| 25900a9911 | |||
| 4277e355e0 | |||
| 855f59b83f | |||
| 9c115c0b65 | |||
| fd726a125f | |||
| 6685669b29 | |||
| 3fa43f02d0 | |||
| 66c34942e6 | |||
| ef1fcd4026 | |||
| a36efa3b1d | |||
| 300a2ae6aa | |||
| 1d5b91b6e2 | |||
| a5c67a7fe1 | |||
| b16d0b1864 | |||
| f90a3b84a8 | |||
| 25a14ed9e7 | |||
| 84790c805c | |||
| a9499f0463 | |||
| c109ed79eb | |||
| 89e5455b29 | |||
| 647762f201 | |||
| b788cc65d1 | |||
| 50ec82213c | |||
| 51c1acd677 | |||
| fd38046113 | |||
| d294f2ecd1 | |||
| 3c7940c6c6 | |||
| 56c000154c | |||
| 8157836b42 | |||
| af0e76b29c | |||
| 39004aac0c | |||
| 8cbd746681 | |||
| dee04a47cb | |||
| 1304a27408 | |||
| 2327b1f622 | |||
| 94fed8ae38 | |||
| 042a26f148 | |||
| e606f0de01 | |||
| ed2253cb6b | |||
| 9a1508b7a6 | |||
| 39f3a31847 | |||
| f0b32961c1 | |||
| a08574b359 | |||
| 26ebb4108d | |||
| 1b95706f70 | |||
| b65ebbe397 | |||
| 0679af3029 | |||
| 8888b42adf | |||
| b8d68739cf | |||
| 4c2b838d7b | |||
| cdb3c29844 | |||
| 22c07bdb82 | |||
| 66513c5758 | |||
| f3c0d20268 | |||
| 622a30899f | |||
| 2c1e8c08ae | |||
| cc52e00d15 | |||
| e762658082 | |||
| 5111cee14b | |||
| 68a6d58339 | |||
| b91c8b27d7 | |||
| 3a91aa5903 | |||
| bfd6331be3 | |||
| 38ba474c1f | |||
| 76272b7e39 | |||
| 1f0c950b17 | |||
| f2998205e1 | |||
| 38e108c1ae | |||
| dec100f8ff | |||
| e89eb332d3 | |||
| cb5b279300 | |||
| 59d681dc00 | |||
| 82dcad569a | |||
| d8aaf3bf55 | |||
| 29b92d89b2 | |||
| 86bcdef13d | |||
| 20e6ae7421 | |||
| 4ed5117900 | |||
| 3388471685 | |||
| 58a957c98a | |||
| 2ad626cd59 | |||
| 6ce0257dc0 | |||
| 43dbd3b28b | |||
| 7671644e87 | |||
| f555a49746 | |||
| 9fe474978a | |||
| 9f8465d364 | |||
| ddfddc33f5 | |||
| 0ec7ed3a18 | |||
| 0bda3f6fd8 | |||
| 59765b14ca | |||
| 08b48371bc | |||
| d3d5b088cd | |||
| 083e931a78 | |||
| 0f0fb7cceb | |||
| f2bbb7f5d0 | |||
| 218c68dee0 | |||
| 202cf24722 | |||
| 1ddacd4454 | |||
| 2dbb091d61 | |||
| dbaf189628 | |||
| 4a0bac5b87 | |||
| 59b7e3dada | |||
| 24223ebe13 | |||
| de6e9ce2d6 | |||
| 470eb0778a | |||
| f075492554 | |||
| 8fb2fac47f | |||
| b1eac1454b | |||
| 84737e0c3b | |||
| c4359a2331 | |||
| 8fa538dbee | |||
| 26d82518fa | |||
| 04a7ba51e4 | |||
| ccf14c51bf | |||
| b97aa8936e | |||
| 98924e362e | |||
| 0384f72cbd | |||
| 0c8a486b6a | |||
| 7954765b73 | |||
| 7604327ca9 | |||
| 20542ac7e9 | |||
| 34137af815 | |||
| 0f2570e51b | |||
| ed917e16fc | |||
| 164510b7b7 | |||
| 6764023016 | |||
| 7c6c1cee6d | |||
| c251c5f413 | |||
| 342e86d7fb | |||
| 176b698f6e | |||
| ddf2ca5630 | |||
| d52061f6d8 | |||
| 3c0a75755b | |||
| 183469ebe5 | |||
| bbe8149e6c | |||
| f876caabe1 | |||
| 87c078f47d | |||
| 5a9292e10a | |||
| a5630022f5 | |||
| babc2aeb9d | |||
| 875b0f98a0 | |||
| 5d1edc71d7 | |||
| e4738e464f | |||
| 85c4963619 | |||
| 8d6707db95 |
@ -14,9 +14,9 @@ steps:
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
- cp vrobbler.conf.test vrobbler.conf
|
- cp vrobbler.conf.test vrobbler.conf
|
||||||
- pip install poetry
|
- pip install poetry
|
||||||
- poetry install --with dev
|
- poetry install --with test
|
||||||
# Start with a fresh database (which is already running as a service from Drone)
|
# Start with a fresh database (which is already running as a service from Drone)
|
||||||
- poetry run pytest --cov-report term:skip-covered --cov=vrobbler tests
|
- poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||||
environment:
|
environment:
|
||||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||||
volumes:
|
volumes:
|
||||||
@ -39,8 +39,8 @@ steps:
|
|||||||
- vrobbler collectstatic --noinput
|
- vrobbler collectstatic --noinput
|
||||||
- immortalctl restart celery && immortalctl restart vrobbler
|
- immortalctl restart celery && immortalctl restart vrobbler
|
||||||
when:
|
when:
|
||||||
branch:
|
ref:
|
||||||
- main
|
- refs/tags/*
|
||||||
- name: build success notification
|
- name: build success notification
|
||||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||||
when:
|
when:
|
||||||
|
|||||||
31
.github/workflows/django.yml
vendored
Normal file
31
.github/workflows/django.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
name: Django CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "develop" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
max-parallel: 4
|
||||||
|
matrix:
|
||||||
|
python-version: [3.9, 3.11, 3.12]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v3
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install poetry
|
||||||
|
poetry install
|
||||||
|
- name: Run Tests
|
||||||
|
run: |
|
||||||
|
pytest
|
||||||
6
Makefile
Normal file
6
Makefile
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
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"
|
||||||
|
logs:
|
||||||
|
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
|
||||||
|
test:
|
||||||
|
pytest vrobbler
|
||||||
1049
PROJECT.org
Normal file
1049
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
@ -1,4 +1,4 @@
|
|||||||
export ENV_PATH=$(poetry env info --path)
|
export ENV_PATH=$(poetry env info --path)
|
||||||
source "${ENV_PATH}/bin/activate"
|
source "${ENV_PATH}/bin/activate"
|
||||||
|
|
||||||
export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
|
#export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
|
||||||
|
|||||||
4664
poetry.lock
generated
4664
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,17 +1,18 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.15.4"
|
version = "0.16.1"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.9,<4.0"
|
python = ">=3.9,<3.12"
|
||||||
Django = "^4.0.3"
|
Django = "^4.0.3"
|
||||||
django-extensions = "^3.1.5"
|
django-extensions = "^3.1.5"
|
||||||
python-dateutil = "^2.8.2"
|
python-dateutil = "^2.8.2"
|
||||||
python-dotenv = "^0.20.0"
|
python-dotenv = "^0.20.0"
|
||||||
python-json-logger = "^2.0.2"
|
python-json-logger = "^2.0.2"
|
||||||
colorlog = "^6.6.0"
|
colorlog = "^6.6.0"
|
||||||
|
httpx = "<=0.27.2"
|
||||||
djangorestframework = "^3.13.1"
|
djangorestframework = "^3.13.1"
|
||||||
Markdown = "^3.3.6"
|
Markdown = "^3.3.6"
|
||||||
django-filter = "^21.1"
|
django-filter = "^21.1"
|
||||||
@ -46,12 +47,23 @@ django-imagekit = "^5.0.0"
|
|||||||
thefuzz = "^0.22.1"
|
thefuzz = "^0.22.1"
|
||||||
dataclass-wizard = "0.22.0"
|
dataclass-wizard = "0.22.0"
|
||||||
webdavclient3 = "^3.14.6"
|
webdavclient3 = "^3.14.6"
|
||||||
boto3 = "^1.35.14"
|
boto3 = "^1.35.37"
|
||||||
|
urllib3 = "<2"
|
||||||
|
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"
|
||||||
|
|
||||||
[tool.poetry.group.dev]
|
[tool.poetry.group.test]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.test.dependencies]
|
||||||
Werkzeug = "2.0.3"
|
Werkzeug = "2.0.3"
|
||||||
black = "^22.3"
|
black = "^22.3"
|
||||||
coverage = "^7.0.5"
|
coverage = "^7.0.5"
|
||||||
@ -60,6 +72,7 @@ pytest = "^7.1"
|
|||||||
pytest-black = "^0.3.12"
|
pytest-black = "^0.3.12"
|
||||||
pytest-cov = "^3.0"
|
pytest-cov = "^3.0"
|
||||||
pytest-django = "^4.5.2"
|
pytest-django = "^4.5.2"
|
||||||
|
pytest-xdist= "^1.0.0"
|
||||||
pytest-flake8 = "^1.1"
|
pytest-flake8 = "^1.1"
|
||||||
pytest-isort = "^3.0"
|
pytest-isort = "^3.0"
|
||||||
pytest-runner = "^6.0"
|
pytest-runner = "^6.0"
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import pytest
|
||||||
from boardgames.bgg import (
|
from boardgames.bgg import (
|
||||||
take_first,
|
take_first,
|
||||||
lookup_boardgame_id_from_bgg,
|
lookup_boardgame_id_from_bgg,
|
||||||
@ -5,12 +6,14 @@ from boardgames.bgg import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Deprecated library")
|
||||||
def test_take_first():
|
def test_take_first():
|
||||||
assert take_first([]) == ""
|
assert take_first([]) == ""
|
||||||
|
|
||||||
assert take_first(["a", "b"]) == "a"
|
assert take_first(["a", "b"]) == "a"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Deprecated library")
|
||||||
def test_lookup_boardgame_id_from_bgg():
|
def test_lookup_boardgame_id_from_bgg():
|
||||||
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
||||||
assert bgg_id == "15"
|
assert bgg_id == "15"
|
||||||
@ -19,6 +22,7 @@ def test_lookup_boardgame_id_from_bgg():
|
|||||||
assert bgg_id == None
|
assert bgg_id == None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Deprecated library")
|
||||||
def test_lookup_boardgame_from_bgg():
|
def test_lookup_boardgame_from_bgg():
|
||||||
bgg_result = lookup_boardgame_from_bgg(15)
|
bgg_result = lookup_boardgame_from_bgg(15)
|
||||||
assert bgg_result.get("bggeek_id") == 15
|
assert bgg_result.get("bggeek_id") == 15
|
||||||
|
|||||||
@ -4,29 +4,40 @@ import pytest
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
from boardgames.models import BoardGame
|
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()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def boardgame_scrobble():
|
def boardgame_scrobble():
|
||||||
user = User.objects.create(
|
first = Person.objects.create(name="First Player")
|
||||||
email="test@exmaple.com", first_name="Test", last_name="User"
|
second = Person.objects.create(name="Second Player")
|
||||||
)
|
|
||||||
return Scrobble.objects.create(
|
return Scrobble.objects.create(
|
||||||
board_game=BoardGame.objects.create(title="Test Board Game"),
|
board_game=BoardGame.objects.create(title="Test Board Game"),
|
||||||
media_type="BoardGame",
|
media_type="BoardGame",
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
log={
|
log={
|
||||||
"players": [
|
"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"}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_track():
|
||||||
|
Track.objects.create(
|
||||||
|
title="Emotion",
|
||||||
|
artist=Artist.objects.create(name="Carly Rae Jepsen"),
|
||||||
|
base_run_time_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MopidyRequest:
|
class MopidyRequest:
|
||||||
name = "Same in the End"
|
name = "Same in the End"
|
||||||
artist = "Sublime"
|
artist = "Sublime"
|
||||||
@ -98,10 +109,18 @@ def mopidy_track_diff_album_request_data(**kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mopidy_podcast():
|
def mopidy_podcast_request_data():
|
||||||
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
|
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
|
||||||
return MopidyRequest(mopidy_uri=mopidy_uri)
|
return MopidyRequest(
|
||||||
|
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||||
|
).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:
|
class JellyfinTrackRequest:
|
||||||
name = "Emotion"
|
name = "Emotion"
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
import pytest
|
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
|
@pytest.mark.django_db
|
||||||
def test_boardgame_log_data(boardgame_scrobble):
|
def test_boardgame_log_data(boardgame_scrobble):
|
||||||
assert not boardgame_scrobble.geo_location
|
|
||||||
assert boardgame_scrobble.logdata == BoardGameLogData(
|
assert boardgame_scrobble.logdata == BoardGameLogData(
|
||||||
players=[
|
players=[
|
||||||
BoardGameScoreLogData(
|
BoardGameScoreLogData(
|
||||||
user_id=1,
|
person_id=1,
|
||||||
name_str="",
|
|
||||||
bgg_username="",
|
bgg_username="",
|
||||||
color="Blue",
|
color="Blue",
|
||||||
character=None,
|
character=None,
|
||||||
@ -18,10 +17,24 @@ def test_boardgame_log_data(boardgame_scrobble):
|
|||||||
score=30,
|
score=30,
|
||||||
win=True,
|
win=True,
|
||||||
new=None,
|
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,
|
difficulty=None,
|
||||||
solo=None,
|
solo=None,
|
||||||
two_handed=None,
|
two_handed=None,
|
||||||
|
|||||||
@ -30,55 +30,26 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.skip("Need to refactor")
|
||||||
"seconds, expected_percent_played, expected_scrobble_id",
|
@pytest.mark.django_db
|
||||||
[
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
(1, 1, 1),
|
@patch(
|
||||||
(58, 96, 1),
|
"music.utils.lookup_album_dict_from_mb",
|
||||||
(59, 98, 1),
|
return_value={"year": "1999", "mb_group_id": 1},
|
||||||
(60, 100, 1),
|
|
||||||
(1, 1, 2),
|
|
||||||
(1, 1, 3),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
@pytest.mark.django_db
|
@patch("music.utils.lookup_track_from_mb", return_value={})
|
||||||
def test_scrobble_mopidy_track(
|
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||||
client,
|
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
|
||||||
mopidy_track,
|
@patch("music.models.Album.fetch_artwork", return_value=None)
|
||||||
valid_auth_token,
|
@patch("music.models.Album.scrape_allmusic", return_value=None)
|
||||||
seconds,
|
|
||||||
expected_percent_played,
|
|
||||||
expected_scrobble_id,
|
|
||||||
):
|
|
||||||
url = reverse("scrobbles:mopidy-webhook")
|
|
||||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
|
||||||
|
|
||||||
# Start new scrobble
|
|
||||||
minutes = 0
|
|
||||||
calc_seconds = seconds
|
|
||||||
if seconds >= 60:
|
|
||||||
minutes = 1
|
|
||||||
calc_seconds = calc_seconds % 10
|
|
||||||
with time_machine.travel(datetime(2024, 1, 14, 12, minutes, calc_seconds)):
|
|
||||||
mopidy_track.request_data["playback_time_ticks"] = seconds * 1000
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
mopidy_track.request_json,
|
|
||||||
content_type="application/json",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data == {"scrobble_id": expected_scrobble_id}
|
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
|
||||||
assert scrobble.percent_played == expected_percent_played
|
|
||||||
assert scrobble.media_obj.__class__ == Track
|
|
||||||
assert scrobble.media_obj.title == "Same in the End"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Allmusic API is unstable")
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_scrobble_mopidy_same_track_different_album(
|
def test_scrobble_mopidy_same_track_different_album(
|
||||||
|
mock_lookup_artist,
|
||||||
|
mock_lookup_album,
|
||||||
|
mock_lookup_track,
|
||||||
|
mock_lookup_artist_tadb,
|
||||||
|
mock_lookup_album_tadb,
|
||||||
|
mock_fetch_artwork,
|
||||||
|
mock_scrape_allmusic,
|
||||||
client,
|
client,
|
||||||
mopidy_track,
|
mopidy_track,
|
||||||
mopidy_track_diff_album_request_data,
|
mopidy_track_diff_album_request_data,
|
||||||
@ -107,13 +78,17 @@ def test_scrobble_mopidy_same_track_different_album(
|
|||||||
assert response.data == {"scrobble_id": 2}
|
assert response.data == {"scrobble_id": 2}
|
||||||
scrobble = Scrobble.objects.last()
|
scrobble = Scrobble.objects.last()
|
||||||
assert scrobble.media_obj.__class__ == Track
|
assert scrobble.media_obj.__class__ == Track
|
||||||
assert scrobble.media_obj.album.name == "Gold"
|
assert scrobble.media_obj.album.name == "Sublime"
|
||||||
assert scrobble.media_obj.title == "Same in the End"
|
assert scrobble.media_obj.title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@patch(
|
||||||
|
"podcasts.sources.podcastindex.lookup_podcast_from_podcastindex",
|
||||||
|
return_value={},
|
||||||
|
)
|
||||||
def test_scrobble_mopidy_podcast(
|
def test_scrobble_mopidy_podcast(
|
||||||
client, mopidy_podcast_request_data, valid_auth_token
|
mock_lookup_podcast, client, mopidy_podcast_request_data, valid_auth_token
|
||||||
):
|
):
|
||||||
url = reverse("scrobbles:mopidy-webhook")
|
url = reverse("scrobbles:mopidy-webhook")
|
||||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
@ -131,6 +106,7 @@ def test_scrobble_mopidy_podcast(
|
|||||||
assert scrobble.media_obj.title == "Up First"
|
assert scrobble.media_obj.title == "Up First"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip("Need to refactor")
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
@patch(
|
@patch(
|
||||||
@ -174,38 +150,103 @@ def test_scrobble_jellyfin_track(
|
|||||||
assert scrobble.media_obj.__class__ == Track
|
assert scrobble.media_obj.__class__ == Track
|
||||||
assert scrobble.media_obj.title == "Emotion"
|
assert scrobble.media_obj.title == "Emotion"
|
||||||
|
|
||||||
with time_machine.travel(datetime(2024, 1, 14, 12, 0, 58)):
|
|
||||||
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
|
@pytest.mark.skip("Need to refactor")
|
||||||
assert response.data == {"scrobble_id": 1}
|
@pytest.mark.django_db
|
||||||
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
|
@patch(
|
||||||
|
"music.utils.lookup_album_dict_from_mb",
|
||||||
|
return_value={"year": "1999", "mb_group_id": 1},
|
||||||
|
)
|
||||||
|
@patch("music.utils.lookup_track_from_mb", return_value={})
|
||||||
|
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||||
|
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
|
||||||
|
@patch("music.models.Album.fetch_artwork", return_value=None)
|
||||||
|
@patch("music.models.Album.scrape_allmusic", return_value=None)
|
||||||
|
def test_scrobble_jellyfin_track_update(
|
||||||
|
mock_lookup_artist,
|
||||||
|
mock_lookup_album,
|
||||||
|
mock_lookup_track,
|
||||||
|
mock_lookup_artist_tadb,
|
||||||
|
mock_lookup_album_tadb,
|
||||||
|
mock_fetch_artwork,
|
||||||
|
mock_scrape_allmusic,
|
||||||
|
test_track,
|
||||||
|
client,
|
||||||
|
jellyfin_track,
|
||||||
|
valid_auth_token,
|
||||||
|
):
|
||||||
|
Scrobble.objects.create(
|
||||||
|
timestamp=timezone.now() - timedelta(minutes=0.5),
|
||||||
|
track=Track.objects.first(),
|
||||||
|
user_id=1,
|
||||||
|
)
|
||||||
|
url = reverse("scrobbles:jellyfin-webhook")
|
||||||
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||||
assert scrobble.media_obj.__class__ == Track
|
"%Y-%m-%d %H:%M:%S"
|
||||||
assert scrobble.media_obj.title == "Emotion"
|
)
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
jellyfin_track.request_json,
|
||||||
|
content_type="application/json",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
with time_machine.travel(datetime(2024, 1, 14, 12, 1, 1)):
|
assert response.status_code == 200
|
||||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
assert response.data == {"scrobble_id": 1}
|
||||||
"%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 response.data == {"scrobble_id": 2}
|
assert scrobble.media_obj.__class__ == Track
|
||||||
|
assert scrobble.media_obj.title == "Emotion"
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
|
||||||
assert scrobble.media_obj.__class__ == Track
|
@pytest.mark.skip("Need to refactor")
|
||||||
assert scrobble.media_obj.title == "Emotion"
|
@pytest.mark.django_db
|
||||||
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
|
@patch(
|
||||||
|
"music.utils.lookup_album_dict_from_mb",
|
||||||
|
return_value={"year": "1999", "mb_group_id": 1},
|
||||||
|
)
|
||||||
|
@patch("music.utils.lookup_track_from_mb", return_value={})
|
||||||
|
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||||
|
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
|
||||||
|
@patch("music.models.Album.fetch_artwork", return_value=None)
|
||||||
|
@patch("music.models.Album.scrape_allmusic", return_value=None)
|
||||||
|
def test_scrobble_jellyfin_track_create_new(
|
||||||
|
mock_lookup_artist,
|
||||||
|
mock_lookup_album,
|
||||||
|
mock_lookup_track,
|
||||||
|
mock_lookup_artist_tadb,
|
||||||
|
mock_lookup_album_tadb,
|
||||||
|
mock_fetch_artwork,
|
||||||
|
mock_scrape_allmusic,
|
||||||
|
test_track,
|
||||||
|
client,
|
||||||
|
jellyfin_track,
|
||||||
|
valid_auth_token,
|
||||||
|
):
|
||||||
|
url = reverse("scrobbles:jellyfin-webhook")
|
||||||
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
|
Scrobble.objects.create(
|
||||||
|
timestamp=timezone.now() - timedelta(minutes=1),
|
||||||
|
track=Track.objects.first(),
|
||||||
|
user_id=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": 2}
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
|
assert scrobble.media_obj.__class__ == Track
|
||||||
|
assert scrobble.media_obj.title == "Emotion"
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
import pytest
|
from videos.sources.imdb import lookup_video_from_imdb
|
||||||
|
|
||||||
from videos.imdb import lookup_video_from_imdb
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Need to sort out third party API testing")
|
def test_lookup_imdb():
|
||||||
def test_lookup_imdb_bad_id(caplog):
|
metadata = lookup_video_from_imdb("8946378")
|
||||||
data = lookup_video_from_imdb("3409324")
|
assert metadata.title == "Knives Out"
|
||||||
assert data is None
|
|
||||||
assert caplog.records[0].levelname == "WARNING"
|
|
||||||
assert caplog.records[0].msg == "IMDB ID should begin with 'tt' 3409324"
|
|
||||||
|
|||||||
9
tests/videos_tests/test_youtube.py
Normal file
9
tests/videos_tests/test_youtube.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import pytest
|
||||||
|
from videos.sources.youtube import lookup_video_from_youtube
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Need to configure Youtube API stuffs in CI")
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_lookup_youtube_id():
|
||||||
|
metadata = lookup_video_from_youtube("RZxs9pAv99Y")
|
||||||
|
assert metadata.title == "No Pun Included's Board Game of the Year 2024"
|
||||||
449
todos.org
449
todos.org
@ -1,449 +0,0 @@
|
|||||||
#+title: TODOs
|
|
||||||
|
|
||||||
A fun way to keep track of things in the project to fix or improve.
|
|
||||||
|
|
||||||
* Version 1.0.0
|
|
||||||
** TODO What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
|
|
||||||
** TODO Add a user profile page with ability to change settings :profiles:improvement:
|
|
||||||
** 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).
|
|
||||||
|
|
||||||
* 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 Add Amazon scraper to look up books when OL fails :books:improvement:
|
|
||||||
** 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 Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
|
|
||||||
** TODO Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug:
|
|
||||||
@ -21,6 +21,10 @@ VROBBLER_THEAUDIODB_API_KEY="<key>"
|
|||||||
VROBBLER_IGDB_CLIENT_ID="<id>"
|
VROBBLER_IGDB_CLIENT_ID="<id>"
|
||||||
VROBBLER_IGDB_CLIENT_SECRET="<key>"
|
VROBBLER_IGDB_CLIENT_SECRET="<key>"
|
||||||
VROBBLER_COMICVINE_API_KEY="<key>"
|
VROBBLER_COMICVINE_API_KEY="<key>"
|
||||||
|
VROBBLER_TODOIST_CLIENT_ID="<id>"
|
||||||
|
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
||||||
|
VROBBLER_GOOGLE_API_KEY="<key>"
|
||||||
|
VROBBLER_LICHESS_API_KEY = "<key>"
|
||||||
|
|
||||||
# Storages
|
# Storages
|
||||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||||
|
|||||||
34
vrobbler/apps/beers/admin.py
Normal file
34
vrobbler/apps/beers/admin.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from beers.models import Beer, BeerProducer, BeerStyle
|
||||||
|
from django.contrib import admin
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
class BeerInline(admin.TabularInline):
|
||||||
|
model = Beer
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BeerStyle)
|
||||||
|
class BeerStyle(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BeerProducer)
|
||||||
|
class BeerProducer(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Beer)
|
||||||
|
class BeerAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"title",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("title",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
5
vrobbler/apps/beers/apps.py
Normal file
5
vrobbler/apps/beers/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BeersConfig(AppConfig):
|
||||||
|
name = "beers"
|
||||||
133
vrobbler/apps/beers/migrations/0001_initial.py
Normal file
133
vrobbler/apps/beers/migrations/0001_initial.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0065_alter_scrobble_log"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BeerProducer",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"location",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Beer",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_seconds",
|
||||||
|
models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_ticks",
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
("ibu", models.SmallIntegerField(blank=True, null=True)),
|
||||||
|
("abv", models.FloatField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"style",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
("non_alcoholic", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"beeradvocate_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"beeradvocate_score",
|
||||||
|
models.SmallIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"untappd_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("beers", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="beeradvocate_image",
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="beers/beeradvcoate/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="producer",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="beers.beerproducer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerproducer",
|
||||||
|
name="beeradvocate_id",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BeerStyle",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="beer",
|
||||||
|
name="beeradvocate_image",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="beer",
|
||||||
|
name="style",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="untappd_image",
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="beers/untappd/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="untappd_rating",
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerproducer",
|
||||||
|
name="untappd_id",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="styles",
|
||||||
|
field=models.ManyToManyField(to="beers.beerstyle"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerproducer",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(default="Untitled", max_length=255),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerproducer",
|
||||||
|
name="uuid",
|
||||||
|
field=models.UUIDField(
|
||||||
|
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerstyle",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(default="Untitled", max_length=255),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerstyle",
|
||||||
|
name="uuid",
|
||||||
|
field=models.UUIDField(
|
||||||
|
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="beer",
|
||||||
|
name="styles",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
related_name="styles", to="beers.beerstyle"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"beers",
|
||||||
|
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="beer",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/beers/migrations/__init__.py
Normal file
0
vrobbler/apps/beers/migrations/__init__.py
Normal file
147
vrobbler/apps/beers/models.py
Normal file
147
vrobbler/apps/beers/models.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class BeerProducer(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
location = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def find_or_create(cls, title: str) -> "BeerProducer":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Beer(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
ibu = models.SmallIntegerField(**BNULL)
|
||||||
|
abv = models.FloatField(**BNULL)
|
||||||
|
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
||||||
|
non_alcoholic = models.BooleanField(default=False)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||||
|
untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)
|
||||||
|
untappd_image_small = ImageSpecField(
|
||||||
|
source="untappd_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
untappd_image_medium = ImageSpecField(
|
||||||
|
source="untappd_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
untappd_rating = models.FloatField(**BNULL)
|
||||||
|
producer = models.ForeignKey(
|
||||||
|
BeerProducer, on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} by {self.producer}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.producer.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Drinking", tags="beer")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def beeradvocate_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.producer and self.beeradvocate_id:
|
||||||
|
if self.beeradvocate_id:
|
||||||
|
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def untappd_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.untappd_id:
|
||||||
|
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
url = ""
|
||||||
|
if self.untappd_image:
|
||||||
|
url = self.untappd_image.url
|
||||||
|
return url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return BeerLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, untappd_id: str) -> "Beer":
|
||||||
|
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
||||||
|
|
||||||
|
if not beer:
|
||||||
|
beer_dict = get_beer_from_untappd_id(untappd_id)
|
||||||
|
producer_dict = {}
|
||||||
|
style_ids = []
|
||||||
|
for key in list(beer_dict.keys()):
|
||||||
|
if "producer__" in key:
|
||||||
|
pkey = key.replace("producer__", "")
|
||||||
|
producer_dict[pkey] = beer_dict.pop(key)
|
||||||
|
if "styles" in key:
|
||||||
|
for style in beer_dict.pop("styles"):
|
||||||
|
style_inst, created = BeerStyle.objects.get_or_create(
|
||||||
|
name=style
|
||||||
|
)
|
||||||
|
style_ids.append(style_inst.id)
|
||||||
|
|
||||||
|
producer, _created = BeerProducer.objects.get_or_create(
|
||||||
|
**producer_dict
|
||||||
|
)
|
||||||
|
beer_dict["producer_id"] = producer.id
|
||||||
|
beer = Beer.objects.create(**beer_dict)
|
||||||
|
for style_id in style_ids:
|
||||||
|
beer.styles.add(style_id)
|
||||||
|
|
||||||
|
return beer
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
142
vrobbler/apps/beers/untappd.py
Normal file
142
vrobbler/apps/beers/untappd.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNTAPPD_URL = "https://untappd.com/beer/{id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_first(key: str, result: dict) -> str:
|
||||||
|
obj = ""
|
||||||
|
if obj_list := result.get(key):
|
||||||
|
obj = obj_list[0]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def get_title_from_soup(soup) -> str:
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
title = soup.find("h1").get_text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def get_description_from_soup(soup) -> str:
|
||||||
|
desc = ""
|
||||||
|
try:
|
||||||
|
desc = (
|
||||||
|
soup.find(class_="beer-descrption-read-less")
|
||||||
|
.get_text()
|
||||||
|
.replace("Show Less", "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return desc
|
||||||
|
|
||||||
|
|
||||||
|
def get_styles_from_soup(soup) -> list[str]:
|
||||||
|
styles = []
|
||||||
|
try:
|
||||||
|
styles = soup.find("p", class_="style").get_text().split(" - ")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return styles
|
||||||
|
|
||||||
|
|
||||||
|
def get_abv_from_soup(soup) -> Optional[float]:
|
||||||
|
abv = None
|
||||||
|
try:
|
||||||
|
abv = soup.find(class_="abv").get_text()
|
||||||
|
if abv:
|
||||||
|
abv = float(abv.strip("\n").strip("% ABV").strip())
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
return abv
|
||||||
|
|
||||||
|
|
||||||
|
def get_ibu_from_soup(soup) -> Optional[int]:
|
||||||
|
ibu = None
|
||||||
|
try:
|
||||||
|
ibu = soup.find(class_="ibu").get_text()
|
||||||
|
if ibu:
|
||||||
|
ibu = int(ibu.strip("\n").strip(" IBU").strip())
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
ibu = None
|
||||||
|
return ibu
|
||||||
|
|
||||||
|
|
||||||
|
def get_rating_from_soup(soup) -> str:
|
||||||
|
rating = ""
|
||||||
|
try:
|
||||||
|
rating = float(
|
||||||
|
soup.find(class_="num").get_text().strip("(").strip(")")
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
rating = None
|
||||||
|
except ValueError:
|
||||||
|
rating = None
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
def get_producer_id_from_soup(soup) -> str:
|
||||||
|
id = ""
|
||||||
|
try:
|
||||||
|
id = soup.find(class_="brewery").find("a")["href"].strip("/")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
return id
|
||||||
|
|
||||||
|
|
||||||
|
def get_producer_name_from_soup(soup) -> str:
|
||||||
|
name = ""
|
||||||
|
try:
|
||||||
|
name = soup.find(class_="brewery").find("a").get_text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def get_beer_from_untappd_id(untappd_id: str) -> dict:
|
||||||
|
beer_url = UNTAPPD_URL.format(id=untappd_id)
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(beer_url, headers=headers)
|
||||||
|
beer_dict = {"untappd_id": untappd_id}
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warn(
|
||||||
|
"Bad response from untappd.com", extra={"response": response}
|
||||||
|
)
|
||||||
|
return beer_dict
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
beer_dict["title"] = get_title_from_soup(soup)
|
||||||
|
beer_dict["description"] = get_description_from_soup(soup)
|
||||||
|
beer_dict["styles"] = get_styles_from_soup(soup)
|
||||||
|
beer_dict["abv"] = get_abv_from_soup(soup)
|
||||||
|
beer_dict["ibu"] = get_ibu_from_soup(soup)
|
||||||
|
beer_dict["untappd_rating"] = get_rating_from_soup(soup)
|
||||||
|
beer_dict["producer__untappd_id"] = get_producer_id_from_soup(soup)
|
||||||
|
beer_dict["producer__name"] = get_producer_name_from_soup(soup)
|
||||||
|
|
||||||
|
return beer_dict
|
||||||
14
vrobbler/apps/beers/urls.py
Normal file
14
vrobbler/apps/beers/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from beers import views
|
||||||
|
|
||||||
|
app_name = "beers"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("beers/", views.BeerListView.as_view(), name="beer_list"),
|
||||||
|
path(
|
||||||
|
"beers/<slug:slug>/",
|
||||||
|
views.BeerDetailView.as_view(),
|
||||||
|
name="beer_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
11
vrobbler/apps/beers/views.py
Normal file
11
vrobbler/apps/beers/views.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from beers.models import Beer
|
||||||
|
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
|
class BeerListView(ScrobbleableListView):
|
||||||
|
model = Beer
|
||||||
|
|
||||||
|
|
||||||
|
class BeerDetailView(ScrobbleableDetailView):
|
||||||
|
model = Beer
|
||||||
@ -1,6 +1,11 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from boardgames.models import BoardGame, BoardGamePublisher
|
from boardgames.models import (
|
||||||
|
BoardGame,
|
||||||
|
BoardGameLocation,
|
||||||
|
BoardGamePublisher,
|
||||||
|
BoardGameDesigner,
|
||||||
|
)
|
||||||
|
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
@ -15,13 +20,34 @@ class BoardGamePublisherAdmin(admin.ModelAdmin):
|
|||||||
ordering = ("-created",)
|
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)
|
@admin.register(BoardGame)
|
||||||
class GameAdmin(admin.ModelAdmin):
|
class BoardGameAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"bggeek_id",
|
"bggeek_id",
|
||||||
"title",
|
"title",
|
||||||
"published_date",
|
"published_year",
|
||||||
)
|
)
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -17,6 +18,8 @@ SEARCH_ID_URL = (
|
|||||||
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||||
)
|
)
|
||||||
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
|
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:
|
def take_first(thing: Optional[list]) -> str:
|
||||||
@ -37,10 +40,9 @@ def take_first(thing: Optional[list]) -> str:
|
|||||||
|
|
||||||
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||||
soup = None
|
soup = None
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
|
||||||
game_id = None
|
game_id = None
|
||||||
url = SEARCH_ID_URL.format(query=title)
|
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:
|
if r.status_code == 200:
|
||||||
soup = BeautifulSoup(r.text, "xml")
|
soup = BeautifulSoup(r.text, "xml")
|
||||||
|
|
||||||
@ -57,7 +59,6 @@ def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
|||||||
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||||
soup = None
|
soup = None
|
||||||
game_dict = {}
|
game_dict = {}
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
|
||||||
|
|
||||||
title = ""
|
title = ""
|
||||||
bgg_id = None
|
bgg_id = None
|
||||||
@ -73,7 +74,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
|||||||
bgg_id = lookup_boardgame_id_from_bgg(title)
|
bgg_id = lookup_boardgame_id_from_bgg(title)
|
||||||
|
|
||||||
url = GAME_ID_URL.format(id=bgg_id)
|
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:
|
if r.status_code == 200:
|
||||||
soup = BeautifulSoup(r.text, "xml")
|
soup = BeautifulSoup(r.text, "xml")
|
||||||
|
|
||||||
@ -100,8 +101,8 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||||
bgg_username = user.profile.bgg_username
|
bgg_username = "secstate" # user.profile.bgg_username
|
||||||
bgg_password = user.profile.bgg_password
|
bgg_password = "yYFCKnfo8AK89lc68q0S"
|
||||||
|
|
||||||
if not bgg_username or bgg_password:
|
if not bgg_username or bgg_password:
|
||||||
return
|
return
|
||||||
@ -109,7 +110,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
|||||||
login_payload = {
|
login_payload = {
|
||||||
"credentials": {"username": bgg_username, "password": bgg_password}
|
"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
|
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
|
||||||
|
|
||||||
@ -119,24 +121,20 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
|||||||
data=json.dumps(login_payload),
|
data=json.dumps(login_payload),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
players = []
|
players = []
|
||||||
if scrobble.metadata:
|
if scrobble.log:
|
||||||
for player in scrobble.metadata.players:
|
for player in scrobble.log.get("players"):
|
||||||
if player["user_id"]:
|
player_person = Person.objects.filter(
|
||||||
player_user = User.objects.filter(
|
id=player.get("person_id")
|
||||||
id=player["user_id"]
|
).first()
|
||||||
).first()
|
if player_person.get("bgg_username"):
|
||||||
if player_user:
|
player["username"] = player_person.get("bgg_username")
|
||||||
if player_user.bgg_username:
|
player["name"] = player_person.get("name")
|
||||||
player["username"] = player_user.bgg_username
|
player["win"] = player.get("win")
|
||||||
else:
|
# player["role"] = player.get("role")
|
||||||
player["name"] = player_user.username
|
player["new"] = player.get("new")
|
||||||
player["win"] = player.get("win")
|
player["score"] = player.get("score")
|
||||||
player["color"] = player.get("color")
|
players.append(player)
|
||||||
player["new"] = player.get("new")
|
|
||||||
player["score"] = player.get("score")
|
|
||||||
players.append(player)
|
|
||||||
|
|
||||||
play_payload = {
|
play_payload = {
|
||||||
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
|
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
|
||||||
@ -150,3 +148,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
|||||||
"objecttype": "thing",
|
"objecttype": "thing",
|
||||||
"ajax": 1,
|
"ajax": 1,
|
||||||
}
|
}
|
||||||
|
r = s.post(
|
||||||
|
"https://boardgamegeek.com/geekplay.php",
|
||||||
|
data=json.dumps(play_payload),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("boardgames", "0006_alter_boardgame_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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 @@
|
|||||||
|
from functools import cached_property
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional, Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django import forms
|
||||||
import requests
|
import requests
|
||||||
from boardgames.bgg import lookup_boardgame_from_bgg
|
from boardgames.sources.bgg import lookup_boardgame_from_bgg
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -12,18 +15,120 @@ from django.urls import reverse
|
|||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
from scrobbles.dataclasses import BoardGameLogData
|
from locations.models import GeoLocation
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from people.models import Person
|
||||||
|
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
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):
|
class BoardGamePublisher(TimeStampedModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
|
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
|
||||||
igdb_id = models.IntegerField(**BNULL)
|
bgg_id = models.IntegerField(**BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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):
|
class BoardGame(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(
|
COMPLETION_PERCENT = getattr(
|
||||||
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
||||||
@ -53,6 +191,14 @@ class BoardGame(ScrobblableMixin):
|
|||||||
publisher = models.ForeignKey(
|
publisher = models.ForeignKey(
|
||||||
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
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)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
description = models.TextField(**BNULL)
|
description = models.TextField(**BNULL)
|
||||||
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
|
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
|
||||||
@ -82,11 +228,23 @@ class BoardGame(ScrobblableMixin):
|
|||||||
options={"quality": 75},
|
options={"quality": 75},
|
||||||
)
|
)
|
||||||
rating = models.FloatField(**BNULL)
|
rating = models.FloatField(**BNULL)
|
||||||
|
bgg_rank = models.IntegerField(**BNULL)
|
||||||
max_players = models.PositiveSmallIntegerField(**BNULL)
|
max_players = models.PositiveSmallIntegerField(**BNULL)
|
||||||
min_players = models.PositiveSmallIntegerField(**BNULL)
|
min_players = models.PositiveSmallIntegerField(**BNULL)
|
||||||
published_date = models.DateField(**BNULL)
|
published_date = models.DateField(**BNULL)
|
||||||
|
published_year = models.IntegerField(**BNULL)
|
||||||
recommended_age = models.PositiveSmallIntegerField(**BNULL)
|
recommended_age = models.PositiveSmallIntegerField(**BNULL)
|
||||||
bggeek_id = models.CharField(max_length=255, **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):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
@ -100,6 +258,10 @@ class BoardGame(ScrobblableMixin):
|
|||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return BoardGameLogData
|
return BoardGameLogData
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Playing", tags="game_die")
|
||||||
|
|
||||||
def primary_image_url(self) -> str:
|
def primary_image_url(self) -> str:
|
||||||
url = ""
|
url = ""
|
||||||
if self.cover:
|
if self.cover:
|
||||||
@ -112,9 +274,6 @@ class BoardGame(ScrobblableMixin):
|
|||||||
link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
|
link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
|
||||||
return link
|
return link
|
||||||
|
|
||||||
def get_start_url(self):
|
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
||||||
|
|
||||||
if not self.published_date or force_update:
|
if not self.published_date or force_update:
|
||||||
@ -127,7 +286,12 @@ class BoardGame(ScrobblableMixin):
|
|||||||
publisher_name = data.pop("publisher_name")
|
publisher_name = data.pop("publisher_name")
|
||||||
|
|
||||||
if year:
|
if year:
|
||||||
data["published_date"] = datetime(int(year), 1, 1)
|
data["published_year"] = int(year)
|
||||||
|
|
||||||
|
if not data["min_players"]:
|
||||||
|
data.pop("min_players")
|
||||||
|
if not data["min_players"]:
|
||||||
|
data.pop("max_players")
|
||||||
|
|
||||||
# Fun trick for updating all fields at once
|
# Fun trick for updating all fields at once
|
||||||
BoardGame.objects.filter(pk=self.id).update(**data)
|
BoardGame.objects.filter(pk=self.id).update(**data)
|
||||||
@ -142,29 +306,58 @@ class BoardGame(ScrobblableMixin):
|
|||||||
|
|
||||||
# Go get cover image if the URL is present
|
# Go get cover image if the URL is present
|
||||||
if cover_url and not self.cover:
|
if cover_url and not self.cover:
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
self.save_image_from_url(cover_url)
|
||||||
r = requests.get(cover_url, headers=headers)
|
|
||||||
logger.debug(r.status_code)
|
def save_image_from_url(self, url):
|
||||||
if r.status_code == 200:
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
r = requests.get(url, headers=headers)
|
||||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
if r.status_code == 200:
|
||||||
logger.debug("Loaded cover image from BGGeek")
|
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||||
|
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls, lookup_id: str, data: Optional[dict] = {}
|
cls, lookup_id: str, data: dict[str, Any] = {}
|
||||||
) -> Optional["BoardGame"]:
|
) -> "BoardGame":
|
||||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
"""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 data or not boardgame:
|
if game:
|
||||||
data = lookup_boardgame_from_bgg(lookup_id)
|
logger.info("Board game exists in database.", extra={"lookup_id": lookup_id, "data": data})
|
||||||
|
return game
|
||||||
|
|
||||||
if data and not boardgame:
|
bgg_data = lookup_boardgame_from_bgg(data.get("name"))
|
||||||
boardgame, created = cls.objects.get_or_create(
|
|
||||||
title=data["title"], bggeek_id=lookup_id
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
boardgame.fix_metadata(data=data)
|
|
||||||
|
|
||||||
return boardgame
|
mechanics = bgg_data.pop("mechanics", [])
|
||||||
|
designers = bgg_data.pop("designers", [])
|
||||||
|
categories = bgg_data.pop("categories", [])
|
||||||
|
publishers = bgg_data.pop("publishers", [])
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|||||||
29
vrobbler/apps/boardgames/sources/bgg.py
Normal file
29
vrobbler/apps/boardgames/sources/bgg.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from typing import Any
|
||||||
|
from boardgamegeek import BGGClient
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
def lookup_boardgame_from_bgg(title: str) -> dict[str, Any]:
|
||||||
|
game_dict = {"title": title}
|
||||||
|
|
||||||
|
bgg = BGGClient(access_token=settings.BGG_ACCESS_TOKEN)
|
||||||
|
|
||||||
|
game = bgg.game(title)
|
||||||
|
|
||||||
|
if game:
|
||||||
|
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["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
|
||||||
|
|
||||||
|
return game_dict
|
||||||
128
vrobbler/apps/boardgames/sources/lichess.py
Normal file
128
vrobbler/apps/boardgames/sources/lichess.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import berserk
|
||||||
|
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 ScrobbleNtfyNotification
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
|
||||||
|
client = berserk.Client(
|
||||||
|
session=berserk.TokenSession(settings.LICHESS_API_KEY)
|
||||||
|
)
|
||||||
|
games = client.games.export_by_player(user.profile.lichess_username)
|
||||||
|
for game_dict in games:
|
||||||
|
chess, created = BoardGame.objects.get_or_create(title="Chess")
|
||||||
|
if created:
|
||||||
|
chess.base_run_time_seconds = 1800
|
||||||
|
chess.bggeek_id = 171
|
||||||
|
chess.save(update_fields=["base_run_time_seconds", "bggeek_id"])
|
||||||
|
scrobble = Scrobble.objects.filter(
|
||||||
|
user_id=user.id,
|
||||||
|
timestamp=game_dict.get("createdAt"),
|
||||||
|
board_game_id=chess.id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if scrobble:
|
||||||
|
continue
|
||||||
|
|
||||||
|
log_data = {
|
||||||
|
"variant": game_dict.get("variant"),
|
||||||
|
"lichess_id": game_dict.get("id"),
|
||||||
|
"rated": game_dict.get("rated"),
|
||||||
|
"speed": game_dict.get("speed"),
|
||||||
|
"moves": game_dict.get("moves"),
|
||||||
|
"players": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
winner = game_dict.get("winner")
|
||||||
|
black_player = game_dict.get("players", {}).get("black", {})
|
||||||
|
white_player = game_dict.get("players", {}).get("white", {})
|
||||||
|
|
||||||
|
user_player = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"lichess_username": user.profile.lichess_username,
|
||||||
|
"bgg_username": user.profile.bgg_username,
|
||||||
|
"color": "",
|
||||||
|
"win": False,
|
||||||
|
}
|
||||||
|
other_player = {"name_str": "", "color": "", "win": False}
|
||||||
|
|
||||||
|
if (
|
||||||
|
black_player.get("user", {}).get("name", "")
|
||||||
|
== user.profile.lichess_username
|
||||||
|
):
|
||||||
|
user_player["color"] = "black"
|
||||||
|
if "aiLevel" in white_player.keys():
|
||||||
|
other_player["name_str"] = "aiLevel_" + str(
|
||||||
|
white_player.get("aiLevel", "")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
other_player["name_str"] = white_player.get("user", {}).get(
|
||||||
|
"name", ""
|
||||||
|
)
|
||||||
|
other_player["lichess_username"] = other_player["name_str"]
|
||||||
|
|
||||||
|
other_player["color"] = "white"
|
||||||
|
if winner == "black":
|
||||||
|
user_player["win"] = True
|
||||||
|
else:
|
||||||
|
other_player["win"] = True
|
||||||
|
if (
|
||||||
|
white_player.get("user", {}).get("name", "")
|
||||||
|
== user.profile.lichess_username
|
||||||
|
):
|
||||||
|
user_player["color"] = "white"
|
||||||
|
if "aiLevel" in black_player.keys():
|
||||||
|
other_player["name_str"] = "aiLevel_" + str(
|
||||||
|
black_player.get("aiLevel", "")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
other_player["name_str"] = black_player.get("user", {}).get(
|
||||||
|
"name", ""
|
||||||
|
)
|
||||||
|
other_player["lichess_username"] = other_player["name_str"]
|
||||||
|
other_player["color"] = "black"
|
||||||
|
if winner == "white":
|
||||||
|
user_player["win"] = True
|
||||||
|
else:
|
||||||
|
other_player["win"] = True
|
||||||
|
|
||||||
|
log_data["players"].append(user_player)
|
||||||
|
log_data["players"].append(other_player)
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"media_type": Scrobble.MediaType.BOARD_GAME,
|
||||||
|
"user_id": user.id,
|
||||||
|
"playback_position_seconds": (
|
||||||
|
game_dict.get("lastMoveAt") - game_dict.get("createdAt")
|
||||||
|
).seconds,
|
||||||
|
"in_progress": False,
|
||||||
|
"played_to_completion": True,
|
||||||
|
"timestamp": game_dict.get("createdAt"),
|
||||||
|
"stop_timestamp": game_dict.get("lastMoveAt"),
|
||||||
|
"board_game_id": chess.id,
|
||||||
|
"source": "Lichess",
|
||||||
|
"timezone": user.profile.timezone,
|
||||||
|
"log": log_data,
|
||||||
|
}
|
||||||
|
if commit:
|
||||||
|
Scrobble.objects.create(**scrobble_dict)
|
||||||
|
return scrobble_dict
|
||||||
|
|
||||||
|
|
||||||
|
def import_chess_games_for_all_users():
|
||||||
|
scrobbles_to_create = []
|
||||||
|
for user in User.objects.filter(profile__lichess_username__isnull=False):
|
||||||
|
scrobble_dict = import_chess_games_for_user_id(user.id)
|
||||||
|
scrobbles_to_create.append(Scrobble(**scrobble_dict))
|
||||||
|
|
||||||
|
if scrobbles_to_create:
|
||||||
|
created = Scrobble.objects.bulk_create(scrobbles_to_create)
|
||||||
|
for scrobble in created:
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
|
return scrobbles_to_create
|
||||||
@ -1,15 +1,14 @@
|
|||||||
from django.views import generic
|
from django.views import generic
|
||||||
from boardgames.models import BoardGame, BoardGamePublisher
|
from boardgames.models import BoardGame, BoardGamePublisher
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
class BoardGameListView(generic.ListView):
|
class BoardGameListView(ScrobbleableListView):
|
||||||
model = BoardGame
|
model = BoardGame
|
||||||
paginate_by = 20
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGameDetailView(generic.DetailView):
|
class BoardGameDetailView(ScrobbleableDetailView):
|
||||||
model = BoardGame
|
model = BoardGame
|
||||||
slug_field = "uuid"
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGamePublisherDetailView(generic.DetailView):
|
class BoardGamePublisherDetailView(generic.DetailView):
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
|
from books.models import Author, Book, Paper
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from books.models import Author, Book, Page
|
|
||||||
|
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
@ -18,22 +16,36 @@ class AuthorAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Page)
|
|
||||||
class PageAdmin(admin.ModelAdmin):
|
|
||||||
date_hierarchy = "created"
|
|
||||||
list_filter = ("book",)
|
|
||||||
ordering = ("book", "number")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Book)
|
@admin.register(Book)
|
||||||
class BookAdmin(admin.ModelAdmin):
|
class BookAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
"isbn",
|
"author",
|
||||||
|
"issue_or_volume",
|
||||||
|
"isbn_13",
|
||||||
|
"first_publish_year",
|
||||||
|
"pages",
|
||||||
|
)
|
||||||
|
search_fields = ("name",)
|
||||||
|
ordering = ("-created",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
|
|
||||||
|
def issue_or_volume(self, obj):
|
||||||
|
return obj.issue_number or obj.volume_number
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Paper)
|
||||||
|
class BookAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"title",
|
||||||
|
"subtitle",
|
||||||
|
"arxiv_id",
|
||||||
"first_publish_year",
|
"first_publish_year",
|
||||||
"pages",
|
"pages",
|
||||||
"openlibrary_id",
|
|
||||||
)
|
)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|||||||
9
vrobbler/apps/books/constants.py
Normal file
9
vrobbler/apps/books/constants.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
BOOKS_TITLES_TO_IGNORE = [
|
||||||
|
"KOReader Quickstart Guide",
|
||||||
|
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
|
||||||
|
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
|
||||||
|
]
|
||||||
|
|
||||||
|
READCOMICSONLINE_URL = "https://readcomicsonline.ru"
|
||||||
@ -1,17 +1,17 @@
|
|||||||
from collections import OrderedDict
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pytz
|
|
||||||
import requests
|
import requests
|
||||||
from books.models import Author, Book
|
from books.constants import BOOKS_TITLES_TO_IGNORE
|
||||||
from books.openlibrary import get_author_openlibrary_id
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
from stream_sqlite import stream_sqlite
|
from stream_sqlite import stream_sqlite
|
||||||
|
from webdav.client import get_webdav_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -62,6 +62,8 @@ def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
|
|||||||
"""Takes a string of authors from KoReader and returns a list
|
"""Takes a string of authors from KoReader and returns a list
|
||||||
of Authors from our database
|
of Authors from our database
|
||||||
"""
|
"""
|
||||||
|
from books.models import Author
|
||||||
|
|
||||||
author_str_list = ko_author_str.split(", ")
|
author_str_list = ko_author_str.split(", ")
|
||||||
author_list = []
|
author_list = []
|
||||||
for author_str in author_str_list:
|
for author_str in author_str_list:
|
||||||
@ -73,36 +75,49 @@ def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
|
|||||||
|
|
||||||
author = Author.objects.filter(name=author_str).first()
|
author = Author.objects.filter(name=author_str).first()
|
||||||
if not author:
|
if not author:
|
||||||
author = Author.objects.create(
|
author = Author.objects.create(name=author_str)
|
||||||
name=author_str,
|
# TODO Move these to async processes after importing
|
||||||
openlibrary_id=get_author_openlibrary_id(author_str),
|
# author.fix_metadata()
|
||||||
)
|
|
||||||
author.fix_metadata()
|
|
||||||
logger.debug(f"Created author {author}")
|
logger.debug(f"Created author {author}")
|
||||||
author_list.append(author)
|
author_list.append(author)
|
||||||
return author_list
|
return author_list
|
||||||
|
|
||||||
|
|
||||||
def create_book_from_row(row: list):
|
def create_book_from_row(row: list):
|
||||||
|
from books.models import Book
|
||||||
|
|
||||||
# No KoReader book yet, create it
|
# No KoReader book yet, create it
|
||||||
author_str = get_author_str_from_row(row)
|
author_str = get_author_str_from_row(row).replace("\x00", "")
|
||||||
total_pages = row[KoReaderBookColumn.PAGES.value]
|
total_pages = row[KoReaderBookColumn.PAGES.value]
|
||||||
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
|
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
|
||||||
|
book_title = row[KoReaderBookColumn.TITLE.value].replace("\x00", "")
|
||||||
|
if " - " in book_title:
|
||||||
|
split_title = book_title.split(" - ")
|
||||||
|
book_title = split_title[0]
|
||||||
|
if (not author_str or author_str == "N/A") and len(split_title) > 1:
|
||||||
|
author_str = split_title[1].split("_")[0]
|
||||||
|
|
||||||
|
clean_row = []
|
||||||
|
for value in row:
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.replace("\x00", "")
|
||||||
|
clean_row.append(value)
|
||||||
|
|
||||||
book = Book.objects.create(
|
book = Book.objects.create(
|
||||||
title=row[KoReaderBookColumn.TITLE.value],
|
title=book_title.replace("_", ":"),
|
||||||
pages=total_pages,
|
pages=total_pages,
|
||||||
koreader_data_by_hash={
|
koreader_data_by_hash={
|
||||||
str(row[KoReaderBookColumn.MD5.value]): {
|
str(row[KoReaderBookColumn.MD5.value]): {
|
||||||
"title": row[KoReaderBookColumn.TITLE.value],
|
"title": book_title,
|
||||||
"author_str": author_str,
|
"author_str": author_str,
|
||||||
"book_id": row[KoReaderBookColumn.ID.value],
|
"book_id": row[KoReaderBookColumn.ID.value],
|
||||||
"pages": total_pages,
|
"raw_row_data": clean_row,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
run_time_seconds=run_time,
|
base_run_time_seconds=run_time,
|
||||||
)
|
)
|
||||||
book.fix_metadata()
|
# TODO Move these to async processes after importing
|
||||||
|
# book.fix_metadata()
|
||||||
|
|
||||||
# Add authors
|
# Add authors
|
||||||
author_list = lookup_or_create_authors_from_author_str(author_str)
|
author_list = lookup_or_create_authors_from_author_str(author_str)
|
||||||
@ -119,15 +134,16 @@ def build_book_map(rows) -> dict:
|
|||||||
primary key IDs for page creation.
|
primary key IDs for page creation.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from books.models import Book
|
||||||
|
|
||||||
book_id_map = {}
|
book_id_map = {}
|
||||||
|
|
||||||
for book_row in rows:
|
for book_row in rows:
|
||||||
if (
|
|
||||||
book_row[KoReaderBookColumn.TITLE.value]
|
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
|
||||||
== "KOReader Quickstart Guide"
|
|
||||||
):
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Ignoring the KOReader quickstart guide. No on wants that."
|
"[build_book_map] Ignoring book title that is likely garbage",
|
||||||
|
extra={"book_row": book_row, "media_type": "Book"},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
book = Book.objects.filter(
|
book = Book.objects.filter(
|
||||||
@ -136,6 +152,15 @@ def build_book_map(rows) -> dict:
|
|||||||
]
|
]
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
if not book:
|
||||||
|
title = (
|
||||||
|
book_row[KoReaderBookColumn.TITLE.value]
|
||||||
|
.split(" - ")[0]
|
||||||
|
.lower()
|
||||||
|
.replace("\x00", "")
|
||||||
|
)
|
||||||
|
book = Book.objects.filter(title=title).first()
|
||||||
|
|
||||||
if not book:
|
if not book:
|
||||||
book = create_book_from_row(book_row)
|
book = create_book_from_row(book_row)
|
||||||
|
|
||||||
@ -253,33 +278,17 @@ def build_scrobbles_from_book_map(
|
|||||||
)
|
)
|
||||||
continue
|
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(
|
# Adjust for Daylight Saving Time
|
||||||
int(first_page.get("start_ts"))
|
if timestamp.dst() == timedelta(
|
||||||
).replace(tzinfo=pytz.timezone(timezone))
|
0
|
||||||
|
) or stop_timestamp.dst() == timedelta(0):
|
||||||
# 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"
|
|
||||||
|
|
||||||
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)
|
timestamp = timestamp - timedelta(hours=1)
|
||||||
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||||
|
|
||||||
@ -311,13 +320,13 @@ def build_scrobbles_from_book_map(
|
|||||||
in_progress=False,
|
in_progress=False,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
long_play_complete=False,
|
long_play_complete=False,
|
||||||
timezone=timezone,
|
timezone=timestamp.tzinfo.name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Then start over
|
# Then start over
|
||||||
should_create_scrobble = False
|
should_create_scrobble = False
|
||||||
playback_position_seconds = 0
|
playback_position_seconds = 0
|
||||||
scrobble_page_data = {}
|
scrobble_page_data = {}
|
||||||
|
|
||||||
# We accumulate pages for the scrobble until we should create a new one
|
# We accumulate pages for the scrobble until we should create a new one
|
||||||
scrobble_page_data[cur_page_number] = stats
|
scrobble_page_data[cur_page_number] = stats
|
||||||
@ -353,9 +362,9 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
|||||||
|
|
||||||
new_scrobbles = []
|
new_scrobbles = []
|
||||||
user = User.objects.filter(id=user_id).first()
|
user = User.objects.filter(id=user_id).first()
|
||||||
tz = pytz.utc
|
tz = ZoneInfo("UTC")
|
||||||
if user:
|
if user:
|
||||||
tz = user.profile.timezone
|
tz = user.profile.tzinfo
|
||||||
|
|
||||||
is_os_file = "https://" not in file_path
|
is_os_file = "https://" not in file_path
|
||||||
if is_os_file:
|
if is_os_file:
|
||||||
@ -397,9 +406,27 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
|||||||
created = []
|
created = []
|
||||||
if new_scrobbles:
|
if new_scrobbles:
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
if created:
|
||||||
|
ScrobbleNtfyNotification(created[-1]).send()
|
||||||
fix_long_play_stats_for_scrobbles(created)
|
fix_long_play_stats_for_scrobbles(created)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created {len(created)} scrobbles",
|
f"Created {len(created)} scrobbles",
|
||||||
extra={"created_scrobbles": created},
|
extra={"created_scrobbles": created},
|
||||||
)
|
)
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_file_from_webdav(user_id: int) -> str:
|
||||||
|
file_path = f"/tmp/{user_id}-koreader-import.sqlite3"
|
||||||
|
client = get_webdav_client(user_id)
|
||||||
|
|
||||||
|
if not client:
|
||||||
|
logger.warning("could not get webdav client for user")
|
||||||
|
# TODO maybe we raise an exception here?
|
||||||
|
return ""
|
||||||
|
|
||||||
|
client.download_sync(
|
||||||
|
remote_path="var/koreader/statistics.sqlite3",
|
||||||
|
local_path=file_path,
|
||||||
|
)
|
||||||
|
return file_path
|
||||||
|
|||||||
40
vrobbler/apps/books/metadata.py
Normal file
40
vrobbler/apps/books/metadata.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
class BookType:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class BookMetadata:
|
||||||
|
title: str
|
||||||
|
run_time_seconds: Optional[int]
|
||||||
|
authors = Optional[str]
|
||||||
|
goodreads_id = Optional[str]
|
||||||
|
koreader_data_by_hash = Optional[dict]
|
||||||
|
isbn = Optional[str]
|
||||||
|
# isbn_13 = Optional[str]
|
||||||
|
# isbn_10 = Optional[str]
|
||||||
|
pages = Optional[int]
|
||||||
|
language = Optional[str]
|
||||||
|
first_publish_year = Optional[int]
|
||||||
|
summary = Optional[str]
|
||||||
|
|
||||||
|
# General
|
||||||
|
cover_url: Optional[str]
|
||||||
|
genres: list[str]
|
||||||
|
|
||||||
|
def __init__(self, title: Optional[str] = ""):
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def as_dict_with_authors_cover_and_genres(self) -> tuple:
|
||||||
|
book_dict = vars(self)
|
||||||
|
authors = book_dict.pop("authors")
|
||||||
|
cover = book_dict.pop("cover_url")
|
||||||
|
genres = book_dict.pop("genres")
|
||||||
|
return book_dict, authors, cover, genres
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-17 20:08
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0020_author_comicvine_data_book_comicvine_data_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="koreader_authors",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="koreader_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="koreader_md5",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"books",
|
||||||
|
"0021_remove_book_koreader_authors_remove_book_koreader_id_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="book",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 4.2.18 on 2025-01-27 04:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0022_alter_book_run_time_seconds"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="book",
|
||||||
|
old_name="isbn",
|
||||||
|
new_name="isbn_13",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="comicvine_data",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="goodreads_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="locg_slug",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="isbn_10",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="publish_date",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
21
vrobbler/apps/books/migrations/0024_book_publisher.py
Normal file
21
vrobbler/apps/books/migrations/0024_book_publisher.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.18 on 2025-01-27 04:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"books",
|
||||||
|
"0023_rename_isbn_book_isbn_13_remove_book_comicvine_data_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="publisher",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-02-18 05:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0067_scrobble_food_alter_scrobble_media_type"),
|
||||||
|
("books", "0024_book_publisher"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="author",
|
||||||
|
name="amazon_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="author",
|
||||||
|
name="librarything_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="author",
|
||||||
|
name="locg_slug",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="author",
|
||||||
|
name="semantic_id",
|
||||||
|
field=models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Paper",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("run_time_seconds", models.IntegerField(default=900)),
|
||||||
|
(
|
||||||
|
"run_time_ticks",
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("semantic_title", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"koreader_data_by_hash",
|
||||||
|
models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"semantic_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"arxiv_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"corpus_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"doi_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
("pages", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"language",
|
||||||
|
models.CharField(blank=True, max_length=4, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first_publish_year",
|
||||||
|
models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("publish_date", models.DateField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"journal",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"journal_volume",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
("abstract", models.TextField(blank=True, null=True)),
|
||||||
|
("num_citations", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"openaccess_pdf_url",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"authors",
|
||||||
|
models.ManyToManyField(blank=True, to="books.author"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-02-18 05:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0025_remove_author_amazon_id_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="paper",
|
||||||
|
name="semantic_title",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-02-18 05:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0026_alter_paper_semantic_title"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="paper",
|
||||||
|
name="num_citations",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="paper",
|
||||||
|
name="tldr",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
16
vrobbler/apps/books/migrations/0028_delete_page.py
Normal file
16
vrobbler/apps/books/migrations/0028_delete_page.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 17:16
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0027_remove_paper_num_citations_paper_tldr"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="Page",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,13 +1,19 @@
|
|||||||
from collections import OrderedDict
|
|
||||||
import logging
|
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
|
from uuid import uuid4
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from books.constants import READCOMICSONLINE_URL
|
||||||
from books.openlibrary import (
|
from books.openlibrary import (
|
||||||
lookup_author_from_openlibrary,
|
lookup_author_from_openlibrary,
|
||||||
lookup_book_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.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
@ -16,25 +22,25 @@ from django.urls import reverse
|
|||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
|
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
|
||||||
from scrobbles.mixins import (
|
from scrobbles.mixins import (
|
||||||
LongPlayScrobblableMixin,
|
LongPlayScrobblableMixin,
|
||||||
ObjectWithGenres,
|
ObjectWithGenres,
|
||||||
ScrobblableMixin,
|
ScrobblableConstants,
|
||||||
)
|
)
|
||||||
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 taggit.managers import TaggableManager
|
||||||
from thefuzz import fuzz
|
from thefuzz import fuzz
|
||||||
from vrobbler.apps.books.comicvine import (
|
|
||||||
ComicVineClient,
|
|
||||||
lookup_comic_from_comicvine,
|
|
||||||
)
|
|
||||||
|
|
||||||
from vrobbler.apps.books.locg import (
|
from vrobbler.apps.books.locg import (
|
||||||
lookup_comic_by_locg_slug,
|
lookup_comic_by_locg_slug,
|
||||||
lookup_comic_from_locg,
|
lookup_comic_from_locg,
|
||||||
lookup_comic_writer_by_locg_slug,
|
lookup_comic_writer_by_locg_slug,
|
||||||
)
|
)
|
||||||
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", "")
|
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||||
|
|
||||||
@ -43,6 +49,34 @@ User = get_user_model()
|
|||||||
BNULL = {"blank": True, "null": True}
|
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):
|
class Author(TimeStampedModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
@ -62,22 +96,27 @@ class Author(TimeStampedModel):
|
|||||||
)
|
)
|
||||||
bio = models.TextField(**BNULL)
|
bio = models.TextField(**BNULL)
|
||||||
wikipedia_url = models.CharField(max_length=255, **BNULL)
|
wikipedia_url = models.CharField(max_length=255, **BNULL)
|
||||||
isni = models.CharField(max_length=255, **BNULL)
|
|
||||||
locg_slug = models.CharField(max_length=255, **BNULL)
|
|
||||||
wikidata_id = models.CharField(max_length=255, **BNULL)
|
wikidata_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
isni = models.CharField(max_length=255, **BNULL)
|
||||||
goodreads_id = models.CharField(max_length=255, **BNULL)
|
goodreads_id = models.CharField(max_length=255, **BNULL)
|
||||||
librarything_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
comicvine_data = models.JSONField(**BNULL)
|
comicvine_data = models.JSONField(**BNULL)
|
||||||
amazon_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
|
semantic_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
||||||
def fix_metadata(self, data_dict: dict = {}):
|
def enrich_from_semantic(self, overwrite=False):
|
||||||
if not data_dict and self.openlibrary_id:
|
...
|
||||||
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
|
|
||||||
|
def enrich_from_google_books(self, overwrite=False):
|
||||||
|
...
|
||||||
|
|
||||||
|
def enrich_from_openlibrary(self, overwrite=False):
|
||||||
|
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
|
||||||
|
|
||||||
if not data_dict or not data_dict.get("name"):
|
if not data_dict or not data_dict.get("name"):
|
||||||
|
logger.warning("Could not find author on openlibrary")
|
||||||
return
|
return
|
||||||
|
|
||||||
headshot_url = data_dict.pop("author_headshot_url", "")
|
headshot_url = data_dict.pop("author_headshot_url", "")
|
||||||
@ -99,21 +138,25 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
|
original_title = models.CharField(max_length=255, **BNULL)
|
||||||
authors = models.ManyToManyField(Author, blank=True)
|
authors = models.ManyToManyField(Author, blank=True)
|
||||||
goodreads_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
# All individual koreader fields are deprecated
|
|
||||||
koreader_id = models.IntegerField(**BNULL)
|
|
||||||
koreader_authors = models.CharField(max_length=255, **BNULL)
|
|
||||||
koreader_md5 = models.CharField(max_length=255, **BNULL)
|
|
||||||
koreader_data_by_hash = models.JSONField(**BNULL)
|
koreader_data_by_hash = models.JSONField(**BNULL)
|
||||||
isbn = models.CharField(max_length=255, **BNULL)
|
isbn_13 = models.CharField(max_length=255, **BNULL)
|
||||||
|
isbn_10 = models.CharField(max_length=255, **BNULL)
|
||||||
pages = models.IntegerField(**BNULL)
|
pages = models.IntegerField(**BNULL)
|
||||||
language = models.CharField(max_length=4, **BNULL)
|
language = models.CharField(max_length=4, **BNULL)
|
||||||
first_publish_year = models.IntegerField(**BNULL)
|
first_publish_year = models.IntegerField(**BNULL)
|
||||||
|
publish_date = models.DateField(**BNULL)
|
||||||
|
publisher = models.CharField(max_length=255, **BNULL)
|
||||||
first_sentence = models.TextField(**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(max_length=5, **BNULL)
|
||||||
|
volume_number = models.IntegerField(max_length=5, **BNULL)
|
||||||
|
# OpenLibrary
|
||||||
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||||
locg_slug = models.CharField(max_length=255, **BNULL)
|
|
||||||
comicvine_data = models.JSONField(**BNULL)
|
|
||||||
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
||||||
cover_small = ImageSpecField(
|
cover_small = ImageSpecField(
|
||||||
source="cover",
|
source="cover",
|
||||||
@ -131,13 +174,21 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
|
|
||||||
genre = TaggableManager(through=ObjectWithGenres)
|
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}"
|
return f"{self.title}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self):
|
def subtitle(self):
|
||||||
return f" by {self.author}"
|
return f" by {self.author}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Reading", tags="book")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return BookLogData
|
return BookLogData
|
||||||
@ -149,12 +200,118 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
url = self.cover_medium.url
|
url = self.cover_medium.url
|
||||||
return url
|
return url
|
||||||
|
|
||||||
def get_start_url(self):
|
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
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:
|
||||||
|
return book
|
||||||
|
|
||||||
|
book_dict = lookup_comic_from_comicvine(title)
|
||||||
|
|
||||||
|
if created or overwrite:
|
||||||
|
author_list = []
|
||||||
|
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(
|
||||||
|
semantic_id=author_dict.get("authorId")
|
||||||
|
)
|
||||||
|
author_list.append(author)
|
||||||
|
if a_created:
|
||||||
|
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 url and (not self.cover or force_update):
|
||||||
|
r = requests.get(url)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{self.title}_{self.uuid}.jpg"
|
||||||
|
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
def fix_metadata(self, data: dict = {}, force_update=False):
|
def fix_metadata(self, data: dict = {}, force_update=False):
|
||||||
if (not self.openlibrary_id or not self.locg_slug) or force_update:
|
if (not self.openlibrary_id or not self.locg_slug) or force_update:
|
||||||
author_name = ""
|
author_name = ""
|
||||||
@ -245,7 +402,7 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
if self.pages:
|
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
|
self.AVG_PAGE_READING_SECONDS
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -329,112 +486,67 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
progress = int((last_scrobble.last_page_read / self.pages) * 100)
|
progress = int((last_scrobble.last_page_read / self.pages) * 100)
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
|
|
||||||
|
class Paper(LongPlayScrobblableMixin):
|
||||||
|
"""Keeps track of Academic Papers"""
|
||||||
|
|
||||||
|
COMPLETION_PERCENT = getattr(settings, "PAPER_COMPLETION_PERCENT", 60)
|
||||||
|
AVG_PAGE_READING_SECONDS = getattr(
|
||||||
|
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||||
|
)
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
semantic_title = models.CharField(max_length=255, **BNULL)
|
||||||
|
authors = models.ManyToManyField(Author, blank=True)
|
||||||
|
koreader_data_by_hash = models.JSONField(**BNULL)
|
||||||
|
arxiv_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
semantic_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
arxiv_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
corpus_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
doi_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
pages = models.IntegerField(**BNULL)
|
||||||
|
language = models.CharField(max_length=4, **BNULL)
|
||||||
|
first_publish_year = models.IntegerField(**BNULL)
|
||||||
|
publish_date = models.DateField(**BNULL)
|
||||||
|
journal = models.CharField(max_length=255, **BNULL)
|
||||||
|
journal_volume = models.CharField(max_length=50, **BNULL)
|
||||||
|
abstract = models.TextField(**BNULL)
|
||||||
|
tldr = models.CharField(max_length=255, **BNULL)
|
||||||
|
openaccess_pdf_url = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
genre = TaggableManager(through=ObjectWithGenres)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
|
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":
|
||||||
book = cls.objects.filter(openlibrary_id=lookup_id).first()
|
paper, created = cls.objects.get_or_create(title=title)
|
||||||
|
if not created and not overwrite:
|
||||||
|
return paper
|
||||||
|
|
||||||
if not book:
|
paper_dict = lookup_paper_from_semantic(title)
|
||||||
data = lookup_book_from_openlibrary(lookup_id, author)
|
|
||||||
|
|
||||||
if not data:
|
if created or overwrite:
|
||||||
logger.error(
|
author_list = []
|
||||||
f"No book found on openlibrary, or in our database for {lookup_id}"
|
author_dicts = paper_dict.pop("author_dicts")
|
||||||
)
|
if author_dicts:
|
||||||
return book
|
for author_dict in author_dicts:
|
||||||
|
if author_dict.get("authorId"):
|
||||||
|
author, a_created = Author.objects.get_or_create(
|
||||||
|
semantic_id=author_dict.get("authorId")
|
||||||
|
)
|
||||||
|
author_list.append(author)
|
||||||
|
if a_created:
|
||||||
|
author.name = author_dict.get("name")
|
||||||
|
author.save()
|
||||||
|
# TODO enrich author?
|
||||||
|
...
|
||||||
|
|
||||||
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
|
for k, v in paper_dict.items():
|
||||||
if book_created:
|
setattr(paper, k, v)
|
||||||
book.fix_metadata(data=data)
|
paper.save()
|
||||||
|
|
||||||
return book
|
if author_list:
|
||||||
|
paper.authors.add(*author_list)
|
||||||
|
genres = paper_dict.pop("genres", [])
|
||||||
class Page(TimeStampedModel):
|
if genres:
|
||||||
"""DEPRECATED, we need to migrate pages into page_data on scrobbles and move on"""
|
paper.genre.add(*genres)
|
||||||
|
return paper
|
||||||
book = models.ForeignKey(Book, on_delete=models.CASCADE)
|
|
||||||
number = models.IntegerField()
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
||||||
start_time = models.DateTimeField(**BNULL)
|
|
||||||
end_time = models.DateTimeField(**BNULL)
|
|
||||||
duration_seconds = models.IntegerField(**BNULL)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = (
|
|
||||||
"book",
|
|
||||||
"number",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Page {self.number} of {self.book.pages} in {self.book.title}"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if not self.end_time and self.duration_seconds:
|
|
||||||
self._set_end_time()
|
|
||||||
|
|
||||||
return super(Page, self).save(*args, **kwargs)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def next(self):
|
|
||||||
page = self.book.page_set.filter(number=self.number + 1).first()
|
|
||||||
if not page:
|
|
||||||
page = (
|
|
||||||
self.book.page_set.filter(created__gt=self.created)
|
|
||||||
.order_by("created")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return page
|
|
||||||
|
|
||||||
@property
|
|
||||||
def previous(self):
|
|
||||||
page = self.book.page_set.filter(number=self.number - 1).first()
|
|
||||||
if not page:
|
|
||||||
page = (
|
|
||||||
self.book.page_set.filter(created__lt=self.created)
|
|
||||||
.order_by("-created")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return page
|
|
||||||
|
|
||||||
@property
|
|
||||||
def seconds_to_next_page(self) -> int:
|
|
||||||
seconds = 999999 # Effectively infnity time as we have no next
|
|
||||||
if not self.end_time:
|
|
||||||
self._set_end_time()
|
|
||||||
if self.next:
|
|
||||||
seconds = (self.next.start_time - self.end_time).seconds
|
|
||||||
return seconds
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_scrobblable(self) -> bool:
|
|
||||||
"""A page defines the start of a scrobble if the seconds to next page
|
|
||||||
are greater than an hour, or 3600 seconds, and it's not a single page,
|
|
||||||
so the next seconds to next_page is less than an hour as well.
|
|
||||||
|
|
||||||
As a special case, the first recorded page is a scrobble, so we establish
|
|
||||||
when the book was started.
|
|
||||||
|
|
||||||
"""
|
|
||||||
is_scrobblable = False
|
|
||||||
over_an_hour_since_last_page = False
|
|
||||||
if not self.previous:
|
|
||||||
is_scrobblable = True
|
|
||||||
|
|
||||||
if self.previous:
|
|
||||||
over_an_hour_since_last_page = (
|
|
||||||
self.previous.seconds_to_next_page >= 3600
|
|
||||||
)
|
|
||||||
blip = self.seconds_to_next_page >= 3600
|
|
||||||
|
|
||||||
if over_an_hour_since_last_page and not blip:
|
|
||||||
is_scrobblable = True
|
|
||||||
return is_scrobblable
|
|
||||||
|
|
||||||
def _set_end_time(self) -> None:
|
|
||||||
if self.end_time:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.end_time = self.start_time + timedelta(
|
|
||||||
seconds=self.duration_seconds
|
|
||||||
)
|
|
||||||
self.save(update_fields=["end_time"])
|
|
||||||
|
|||||||
0
vrobbler/apps/books/sources/__init__.py
Normal file
0
vrobbler/apps/books/sources/__init__.py
Normal file
@ -3,7 +3,6 @@ ComicVine API Information & Documentation:
|
|||||||
https://comicvine.gamespot.com/api/
|
https://comicvine.gamespot.com/api/
|
||||||
https://comicvine.gamespot.com/api/documentation
|
https://comicvine.gamespot.com/api/documentation
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@ -200,34 +199,72 @@ class ComicVineClient(object):
|
|||||||
|
|
||||||
|
|
||||||
def lookup_comic_from_comicvine(title: str) -> dict:
|
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", "")
|
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||||
if not 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 {}
|
return {}
|
||||||
|
|
||||||
client = ComicVineClient(
|
client = ComicVineClient(
|
||||||
api_key=getattr(settings, "COMICVINE_API_KEY", None)
|
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():
|
raw_results = client.search(title).get("results")
|
||||||
logger.warn("No result found on ComicVine", extra={"title": title})
|
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 {}
|
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 = {
|
data_dict = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"cover_url": result.get("image").get("original_url"),
|
"original_title": original_title,
|
||||||
"comicvine_data": {
|
"issue_number": found_result.get("issue_number"),
|
||||||
"id": result.get("id"),
|
"volume_number": found_result.get("volume_number"),
|
||||||
"site_detail_url": result.get("site_detail_url"),
|
"cover_url": found_result.get("image").get("original_url"),
|
||||||
"description": result.get("description"),
|
"comicvine_id": found_result.get("id"),
|
||||||
"image": result.get("image").get("original_url"),
|
"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
|
return data_dict
|
||||||
76
vrobbler/apps/books/sources/google.py
Normal file
76
vrobbler/apps/books/sources/google.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
import requests
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_book_from_google(title: str) -> dict:
|
||||||
|
book_dict = {"title": title}
|
||||||
|
|
||||||
|
url = GOOGLE_BOOKS_URL.format(title=title, key=API_KEY)
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"Bad response from Google", extra={"response": response}
|
||||||
|
)
|
||||||
|
return book_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 = ""
|
||||||
|
isbn_10 = ""
|
||||||
|
for ident in google_result.get("industryIdentifiers", []):
|
||||||
|
if ident.get("type") == "ISBN_13":
|
||||||
|
isbn_13 = ident.get("identifier")
|
||||||
|
if ident.get("type") == "ISBN_10":
|
||||||
|
isbn_10 = ident.get("identifier")
|
||||||
|
# TODO this may lead to issues with the first get if Google changes our title
|
||||||
|
# book_metadata.title = google_result.get("title")
|
||||||
|
# if google_result.get("subtitle"):
|
||||||
|
# book_metadata["title"] = ": ".join(
|
||||||
|
# [google_result.get("title"), google_result.get("subtitle")]
|
||||||
|
# )
|
||||||
|
# book_dict["subtitle"] = google_result.get("subtitle")
|
||||||
|
book_dict["authors"] = google_result.get("authors")
|
||||||
|
book_dict["publisher"] = google_result.get("publisher")
|
||||||
|
book_dict["first_publish_year"] = publish_date.year
|
||||||
|
book_dict["pages"] = google_result.get("pageCount")
|
||||||
|
book_dict["isbn_13"] = isbn_13
|
||||||
|
book_dict["isbn_10"] = isbn_10
|
||||||
|
book_dict["publish_date"] = google_result.get("publishedDate")
|
||||||
|
if len(book_dict["publish_date"]) == 4:
|
||||||
|
book_dict["publish_date"] = f"{book_dict['publish_date']}-1-1"
|
||||||
|
book_dict["language"] = google_result.get("language")
|
||||||
|
book_dict["summary"] = google_result.get("description")
|
||||||
|
book_dict["genres"] = google_result.get("categories")
|
||||||
|
book_dict["cover_url"] = (
|
||||||
|
google_result.get("imageLinks", {})
|
||||||
|
.get("thumbnail", "")
|
||||||
|
.replace("zoom=1", "zoom=15")
|
||||||
|
.replace("&edge=curl", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
76
vrobbler/apps/books/sources/semantic.py
Normal file
76
vrobbler/apps/books/sources/semantic.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
PAPER_SEARCH_URL = (
|
||||||
|
"https://api.semanticscholar.org/graph/v1/paper/search/match?query={}"
|
||||||
|
)
|
||||||
|
PAPER_DETAIL_URL = "https://api.semanticscholar.org/graph/v1/paper/{}?fields=title,authors,url,year,abstract,externalIds,citationCount,referenceCount,journal,fieldsOfStudy,publicationDate,openAccessPdf"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_result(url):
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"Bad response from Semantic", extra={"response": response}
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_paper_from_semantic(title: str) -> dict:
|
||||||
|
paper_dict = {"title": title}
|
||||||
|
|
||||||
|
response = get_api_result(PAPER_SEARCH_URL.format(title))
|
||||||
|
if not response:
|
||||||
|
return paper_dict
|
||||||
|
|
||||||
|
semantic_id = json.loads(response.content).get("data")[0].get("paperId")
|
||||||
|
response = get_api_result(PAPER_DETAIL_URL.format(semantic_id))
|
||||||
|
|
||||||
|
result = json.loads(response.content)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return paper_dict
|
||||||
|
|
||||||
|
page_str = result.get("journal", {}).get("pages")
|
||||||
|
if page_str:
|
||||||
|
try:
|
||||||
|
start_page = page_str.split(" - ")[0]
|
||||||
|
end_page = page_str.split(" - ")[1]
|
||||||
|
paper_dict["pages"] = int(end_page) - int(start_page)
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
paper_dict["semantic_id"] = result.get("paperId")
|
||||||
|
paper_dict["doi_id"] = result.get("externalIds", {}).get("DOI")
|
||||||
|
paper_dict["arxiv_id"] = result.get("externalIds", {}).get("ArXiv")
|
||||||
|
paper_dict["pubmed_id"] = result.get("externalIds", {}).get("PubMed")
|
||||||
|
paper_dict["corpus_id"] = result.get("externalIds", {}).get("CorpusId")
|
||||||
|
paper_dict["semantic_title"] = result.get("title")
|
||||||
|
paper_dict["first_publish_year"] = result.get("year")
|
||||||
|
paper_dict["publish_date"] = datetime.strptime(
|
||||||
|
result.get("publicationDate", "1950-01-01"), "%Y-%m-%d"
|
||||||
|
)
|
||||||
|
paper_dict["abstract"] = result.get("abstract")
|
||||||
|
paper_dict["tldr"] = result.get("bib", {}).get("abstract")
|
||||||
|
paper_dict["journal"] = result.get("journal", {}).get("name")
|
||||||
|
paper_dict["journal_volume"] = result.get("journal", {}).get("volume")
|
||||||
|
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
|
||||||
|
"url"
|
||||||
|
)
|
||||||
|
paper_dict["base_run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
|
||||||
|
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||||
|
)
|
||||||
|
paper_dict["author_dicts"] = result.get("authors")
|
||||||
|
paper_dict["genres"] = result.get("fieldsOfStudy")
|
||||||
|
|
||||||
|
return paper_dict
|
||||||
@ -5,6 +5,7 @@ import pytest
|
|||||||
from books.openlibrary import lookup_book_from_openlibrary
|
from books.openlibrary import lookup_book_from_openlibrary
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip()
|
||||||
def test_lookup_modern_book():
|
def test_lookup_modern_book():
|
||||||
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
|
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
|
||||||
assert book.get("title") == "Matrix"
|
assert book.get("title") == "Matrix"
|
||||||
@ -12,6 +13,7 @@ def test_lookup_modern_book():
|
|||||||
assert book.get("ol_author_id") == "OL3675729A"
|
assert book.get("ol_author_id") == "OL3675729A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip()
|
||||||
def test_lookup_classic_book():
|
def test_lookup_classic_book():
|
||||||
book = lookup_book_from_openlibrary(
|
book = lookup_book_from_openlibrary(
|
||||||
"The Life of Castruccio Castracani", "Machiavelli"
|
"The Life of Castruccio Castracani", "Machiavelli"
|
||||||
@ -21,6 +23,7 @@ def test_lookup_classic_book():
|
|||||||
assert book.get("ol_author_id") == "OL23135A"
|
assert book.get("ol_author_id") == "OL23135A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip()
|
||||||
def test_lookup_foreign_book():
|
def test_lookup_foreign_book():
|
||||||
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
|
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
|
||||||
assert book.get("title") == "Ravage"
|
assert book.get("title") == "Ravage"
|
||||||
|
|||||||
@ -5,14 +5,14 @@ app_name = "books"
|
|||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("book/", views.BookListView.as_view(), name="book_list"),
|
path("books/", views.BookListView.as_view(), name="book_list"),
|
||||||
path(
|
path(
|
||||||
"book/<slug:slug>/",
|
"books/<slug:slug>/",
|
||||||
views.BookDetailView.as_view(),
|
views.BookDetailView.as_view(),
|
||||||
name="book_detail",
|
name="book_detail",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"author/<slug:slug>/",
|
"authors/<slug:slug>/",
|
||||||
views.AuthorDetailView.as_view(),
|
views.AuthorDetailView.as_view(),
|
||||||
name="author_detail",
|
name="author_detail",
|
||||||
),
|
),
|
||||||
|
|||||||
59
vrobbler/apps/books/utils.py
Normal file
59
vrobbler/apps/books/utils.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bricksets", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="brickset",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_extensions.db.models import TimeStampedModel
|
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
from scrobbles.dataclasses import BrickSetLogData
|
|
||||||
from scrobbles.mixins import LongPlayScrobblableMixin
|
from scrobbles.mixins import LongPlayScrobblableMixin
|
||||||
|
|
||||||
|
from vrobbler.apps.scrobbles.dataclasses import (
|
||||||
|
BaseLogData,
|
||||||
|
LongPlayLogData,
|
||||||
|
WithPeopleLogData,
|
||||||
|
)
|
||||||
|
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BrickSetLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BrickSet(LongPlayScrobblableMixin):
|
class BrickSet(LongPlayScrobblableMixin):
|
||||||
""""""
|
""""""
|
||||||
|
|
||||||
number = models.CharField(max_length=10, **BNULL)
|
number = models.CharField(max_length=10, **BNULL)
|
||||||
release_year = models.IntegerField(**BNULL)
|
release_year = models.IntegerField(**BNULL)
|
||||||
piece_count = 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)
|
lego_item_number = models.CharField(max_length=10, **BNULL)
|
||||||
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
|
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
|
||||||
box_image_small = ImageSpecField(
|
box_image_small = ImageSpecField(
|
||||||
@ -48,13 +60,23 @@ class BrickSet(LongPlayScrobblableMixin):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
|
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
|
@property
|
||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return BrickSetLogData
|
return BrickSetLogData
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, title: str) -> "BrickSet":
|
def find_or_create(cls, brickset_id: str) -> "BrickSet":
|
||||||
return cls.objects.filter(title=title).first()
|
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
|
@property
|
||||||
def primary_image_url(self) -> str:
|
def primary_image_url(self) -> str:
|
||||||
|
|||||||
28
vrobbler/apps/foods/admin.py
Normal file
28
vrobbler/apps/foods/admin.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from foods.models import Food, FoodCategory
|
||||||
|
from django.contrib import admin
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
class FoodInline(admin.TabularInline):
|
||||||
|
model = Food
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FoodCategory)
|
||||||
|
class FoodCategoryAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Food)
|
||||||
|
class FoodAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"title",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("title",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
143
vrobbler/apps/foods/allrecipe.py
Normal file
143
vrobbler/apps/foods/allrecipe.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import urllib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALLRECIPE_URL = "https://allrecipe.com/{id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_first(key: str, result: dict) -> str:
|
||||||
|
obj = ""
|
||||||
|
if obj_list := result.get(key):
|
||||||
|
obj = obj_list[0]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def get_title_from_soup(soup) -> str:
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
title = soup.find("h1").get_text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def get_description_from_soup(soup) -> str:
|
||||||
|
desc = ""
|
||||||
|
try:
|
||||||
|
desc = (
|
||||||
|
soup.find(class_="beer-descrption-read-less")
|
||||||
|
.get_text()
|
||||||
|
.replace("Show Less", "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return desc
|
||||||
|
|
||||||
|
|
||||||
|
def get_styles_from_soup(soup) -> list[str]:
|
||||||
|
styles = []
|
||||||
|
try:
|
||||||
|
styles = soup.find("p", class_="style").get_text().split(" - ")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return styles
|
||||||
|
|
||||||
|
|
||||||
|
def get_abv_from_soup(soup) -> Optional[float]:
|
||||||
|
abv = None
|
||||||
|
try:
|
||||||
|
abv = soup.find(class_="abv").get_text()
|
||||||
|
if abv:
|
||||||
|
abv = float(abv.strip("\n").strip("% ABV").strip())
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
return abv
|
||||||
|
|
||||||
|
|
||||||
|
def get_ibu_from_soup(soup) -> Optional[int]:
|
||||||
|
ibu = None
|
||||||
|
try:
|
||||||
|
ibu = soup.find(class_="ibu").get_text()
|
||||||
|
if ibu:
|
||||||
|
ibu = int(ibu.strip("\n").strip(" IBU").strip())
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
ibu = None
|
||||||
|
return ibu
|
||||||
|
|
||||||
|
|
||||||
|
def get_rating_from_soup(soup) -> str:
|
||||||
|
rating = ""
|
||||||
|
try:
|
||||||
|
rating = float(
|
||||||
|
soup.find(class_="num").get_text().strip("(").strip(")")
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
rating = None
|
||||||
|
except ValueError:
|
||||||
|
rating = None
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
def get_producer_id_from_soup(soup) -> str:
|
||||||
|
id = ""
|
||||||
|
try:
|
||||||
|
id = soup.find(class_="brewery").find("a")["href"].strip("/")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
return id
|
||||||
|
|
||||||
|
|
||||||
|
def get_producer_name_from_soup(soup) -> str:
|
||||||
|
name = ""
|
||||||
|
try:
|
||||||
|
name = soup.find(class_="brewery").find("a").get_text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def get_food_from_allrecipe_id(allrecipe_id: str) -> dict:
|
||||||
|
url = ALLRECIPE_URL.format(id=allrecipe_id)
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
food_dict = {"allrecipe_id": allrecipe_id}
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warn(
|
||||||
|
"Bad response from allrecipe", extra={"response": response}
|
||||||
|
)
|
||||||
|
return food_dict
|
||||||
|
|
||||||
|
import pdb
|
||||||
|
|
||||||
|
pdb.set_trace()
|
||||||
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
food_dict["title"] = get_title_from_soup(soup)
|
||||||
|
food_dict["description"] = get_description_from_soup(soup)
|
||||||
|
food_dict["allrecipe_rating"] = get_rating_from_soup(soup)
|
||||||
|
|
||||||
|
return food_dict
|
||||||
5
vrobbler/apps/foods/apps.py
Normal file
5
vrobbler/apps/foods/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FoodsConfig(AppConfig):
|
||||||
|
name = "foods"
|
||||||
151
vrobbler/apps/foods/migrations/0001_initial.py
Normal file
151
vrobbler/apps/foods/migrations/0001_initial.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-11-24 14:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0066_scrobble_beer_alter_scrobble_media_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FoodCategory",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"allrecipe_image",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="food/recipe/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"allrecipe_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Food",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_seconds",
|
||||||
|
models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_ticks",
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"allrecipe_image",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="food/recipe/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"allrecipe_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("allrecipe_rating", models.FloatField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"category",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="foods.foodcategory",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("foods", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="food",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
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
vrobbler/apps/foods/migrations/__init__.py
Normal file
0
vrobbler/apps/foods/migrations/__init__.py
Normal file
124
vrobbler/apps/foods/models.py
Normal file
124
vrobbler/apps/foods/models.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
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 BaseLogData, WithPeopleLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FoodLogData(BaseLogData, WithPeopleLogData):
|
||||||
|
calories: Optional[int] = None
|
||||||
|
meal: Optional[str] = None
|
||||||
|
rating: 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",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
allrecipe_image_medium = ImageSpecField(
|
||||||
|
source="recipe_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
allrecipe_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
def find_or_create(cls, title: str) -> "FoodCategory":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Food(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
calories = models.IntegerField(**BNULL)
|
||||||
|
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
|
||||||
|
allrecipe_image_small = ImageSpecField(
|
||||||
|
source="allrecipe_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
allrecipe_image_medium = ImageSpecField(
|
||||||
|
source="allrecipe_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
allrecipe_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
allrecipe_rating = models.FloatField(**BNULL)
|
||||||
|
category = models.ForeignKey(
|
||||||
|
FoodCategory, on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("foods:food_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
if self.category:
|
||||||
|
return self.category.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Eating", tags="food")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allrecipe_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.producer and self.allrecipe_id:
|
||||||
|
if self.allrecipe_id:
|
||||||
|
link = f"https://www.allrecipe.com/{self.allrecipe_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
url = ""
|
||||||
|
if self.allrecipe_image:
|
||||||
|
url = self.allrecipe_image.url
|
||||||
|
return url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return FoodLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, allrecipe_id: str) -> "Food":
|
||||||
|
food = cls.objects.filter(allrecipe_id=allrecipe_id).first()
|
||||||
|
|
||||||
|
if not food:
|
||||||
|
food_dict = get_food_from_allrecipe_id(allrecipe_id)
|
||||||
|
# category_dict = {}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
return food
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, food=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
14
vrobbler/apps/foods/urls.py
Normal file
14
vrobbler/apps/foods/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from foods import views
|
||||||
|
|
||||||
|
app_name = "foods"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("foods/", views.FoodListView.as_view(), name="food_list"),
|
||||||
|
path(
|
||||||
|
"foods/<slug:slug>/",
|
||||||
|
views.FoodDetailView.as_view(),
|
||||||
|
name="food_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
11
vrobbler/apps/foods/views.py
Normal file
11
vrobbler/apps/foods/views.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from foods.models import Food
|
||||||
|
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
|
class FoodListView(ScrobbleableListView):
|
||||||
|
model = Food
|
||||||
|
|
||||||
|
|
||||||
|
class FoodDetailView(ScrobbleableDetailView):
|
||||||
|
model = Food
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("lifeevents", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="lifeevent",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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.apps import apps
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from scrobbles.dataclasses import LifeEventLogData
|
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LifeEventLogData(BaseLogData, WithPeopleLogData):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LifeEvent(ScrobblableMixin):
|
class LifeEvent(ScrobblableMixin):
|
||||||
description = models.TextField(**BNULL)
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
@ -22,6 +28,10 @@ class LifeEvent(ScrobblableMixin):
|
|||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return LifeEventLogData
|
return LifeEventLogData
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Experiencing", tags="camping")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, title: str) -> "LifeEvent":
|
def find_or_create(cls, title: str) -> "LifeEvent":
|
||||||
return cls.objects.filter(title=title).first()
|
return cls.objects.filter(title=title).first()
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("locations", "0006_delete_rawgeolocation"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="geolocation",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,14 +1,14 @@
|
|||||||
from decimal import Decimal, getcontext
|
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -17,6 +17,9 @@ User = get_user_model()
|
|||||||
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
|
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
|
||||||
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
|
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GeoLocationLogData(BaseLogData, WithPeopleLogData):
|
||||||
|
pass
|
||||||
|
|
||||||
class GeoLocation(ScrobblableMixin):
|
class GeoLocation(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
||||||
@ -38,9 +41,13 @@ class GeoLocation(ScrobblableMixin):
|
|||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
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
|
@classmethod
|
||||||
def find_or_create(cls, data_dict: Dict) -> "GeoLocation":
|
def find_or_create(cls, data_dict: Dict) -> "GeoLocation":
|
||||||
"""Given a data dict from GPSLogger, does the heavy lifting of looking up
|
"""Given a data dict from GPSLogger, does the heavy lifting of looking up
|
||||||
@ -90,6 +97,12 @@ class GeoLocation(ScrobblableMixin):
|
|||||||
return f"{self.lat} x {self.lon}"
|
return f"{self.lat} x {self.lon}"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(
|
||||||
|
verb="Going", tags="world_map", priority="low"
|
||||||
|
)
|
||||||
|
|
||||||
def loc_diff(self, old_lat_lon: tuple) -> tuple:
|
def loc_diff(self, old_lat_lon: tuple) -> tuple:
|
||||||
return (
|
return (
|
||||||
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
|
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
|
||||||
|
|||||||
@ -8,11 +8,11 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"locations/",
|
"locations/",
|
||||||
views.GeoLocationListView.as_view(),
|
views.GeoLocationListView.as_view(),
|
||||||
name="geo_locations_list",
|
name="geolocation_list",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"locations/<slug:slug>/",
|
"locations/<slug:slug>/",
|
||||||
views.GeoLocationDetailView.as_view(),
|
views.GeoLocationDetailView.as_view(),
|
||||||
name="geo_location_detail",
|
name="geolocation_detail",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("moods", "0002_mood_image"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="mood",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.dataclasses import BaseLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MoodLogData(BaseLogData):
|
||||||
|
reasons: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Mood(ScrobblableMixin):
|
class Mood(ScrobblableMixin):
|
||||||
description = models.TextField(**BNULL)
|
description = models.TextField(**BNULL)
|
||||||
image = models.ImageField(upload_to="moods/", **BNULL)
|
image = models.ImageField(upload_to="moods/", **BNULL)
|
||||||
@ -36,15 +42,16 @@ class Mood(ScrobblableMixin):
|
|||||||
return str(self.uuid)
|
return str(self.uuid)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
|
return reverse("moods:mood_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
def get_start_url(self):
|
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self) -> str:
|
def subtitle(self) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Feeling", tags="thinking")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return MoodLogData
|
return MoodLogData
|
||||||
|
|||||||
@ -5,10 +5,10 @@ app_name = "moods"
|
|||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("moods/", views.MoodListView.as_view(), name="mood-list"),
|
path("moods/", views.MoodListView.as_view(), name="mood_list"),
|
||||||
path(
|
path(
|
||||||
"moods/<slug:slug>/",
|
"moods/<slug:slug>/",
|
||||||
views.MoodDetailView.as_view(),
|
views.MoodDetailView.as_view(),
|
||||||
name="mood-detail",
|
name="mood_detail",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class AlbumAdmin(admin.ModelAdmin):
|
|||||||
"theaudiodb_mood",
|
"theaudiodb_mood",
|
||||||
"musicbrainz_id",
|
"musicbrainz_id",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("album_artist",)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"theaudiodb_score",
|
"theaudiodb_score",
|
||||||
"theaudiodb_genre",
|
"theaudiodb_genre",
|
||||||
@ -49,13 +50,17 @@ class TrackAdmin(admin.ModelAdmin):
|
|||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
"album",
|
"primary_album",
|
||||||
"artist",
|
"artist",
|
||||||
"musicbrainz_id",
|
"musicbrainz_id",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("artist", "albums", "album")
|
||||||
list_filter = ("album", "artist")
|
list_filter = ("album", "artist")
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
filter_horizontal = [
|
||||||
|
"albums",
|
||||||
|
]
|
||||||
inlines = [
|
inlines = [
|
||||||
ScrobbleInline,
|
ScrobbleInline,
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
import logging
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import pylast
|
|
||||||
import pytz
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils import timezone
|
|
||||||
from music.utils import (
|
|
||||||
get_or_create_album,
|
|
||||||
get_or_create_artist,
|
|
||||||
get_or_create_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:
|
|
||||||
timestamp = lfm_scrobble.pop("timestamp")
|
|
||||||
|
|
||||||
artist = get_or_create_artist(lfm_scrobble.pop("artist"))
|
|
||||||
album = get_or_create_album(lfm_scrobble.pop("album"), artist)
|
|
||||||
|
|
||||||
lfm_scrobble["artist"] = artist
|
|
||||||
if album:
|
|
||||||
lfm_scrobble["album"] = album
|
|
||||||
track = get_or_create_track(**lfm_scrobble)
|
|
||||||
|
|
||||||
timezone = settings.TIME_ZONE
|
|
||||||
if self.vrobbler_user.profile:
|
|
||||||
timezone = self.vrobbler_user.profile.timezone
|
|
||||||
|
|
||||||
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)
|
|
||||||
logger.info(
|
|
||||||
f"Created {len(created)} scrobbles",
|
|
||||||
extra={"created_scrobbles": created},
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
|
||||||
run_time = int(scrobble.track.get_duration() / 1000)
|
|
||||||
mbid = scrobble.track.get_mbid()
|
|
||||||
artist = scrobble.track.get_artist().name
|
|
||||||
except pylast.MalformedResponseError as e:
|
|
||||||
logger.warn(e)
|
|
||||||
except pylast.WSError as e:
|
|
||||||
logger.warn(
|
|
||||||
"LastFM barfed trying to get the track for {scrobble.track}"
|
|
||||||
)
|
|
||||||
except pylast.NetworkError as e:
|
|
||||||
logger.warn(
|
|
||||||
"LastFM barfed trying to get the track for {scrobble.track}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not artist:
|
|
||||||
logger.warn(f"Silly LastFM, no artist found for {scrobble}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
timestamp = datetime.utcfromtimestamp(
|
|
||||||
int(scrobble.timestamp)
|
|
||||||
).replace(tzinfo=pytz.utc)
|
|
||||||
|
|
||||||
logger.info(f"{artist},{scrobble.track.title},{timestamp}")
|
|
||||||
scrobbles.append(
|
|
||||||
{
|
|
||||||
"artist": artist,
|
|
||||||
"album": scrobble.album,
|
|
||||||
"title": scrobble.track.title,
|
|
||||||
"mbid": mbid,
|
|
||||||
"run_time_seconds": run_time,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return scrobbles
|
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0023_alter_track_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="track",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
vrobbler/apps/music/migrations/0025_artist_alt_names.py
Normal file
18
vrobbler/apps/music/migrations/0025_artist_alt_names.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 00:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0024_alter_track_run_time_seconds"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="artist",
|
||||||
|
name="alt_names",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
vrobbler/apps/music/migrations/0026_album_alt_names.py
Normal file
18
vrobbler/apps/music/migrations/0026_album_alt_names.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 00:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0025_artist_alt_names"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="album",
|
||||||
|
name="alt_names",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
vrobbler/apps/music/migrations/0027_track_albums.py
Normal file
20
vrobbler/apps/music/migrations/0027_track_albums.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-20 20:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0026_album_alt_names"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="track",
|
||||||
|
name="albums",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, null=True, related_name="tracks", to="music.album"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
vrobbler/apps/music/migrations/0028_alter_track_albums.py
Normal file
20
vrobbler/apps/music/migrations/0028_alter_track_albums.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-25 14:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0027_track_albums"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="track",
|
||||||
|
name="albums",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
related_name="tracks", to="music.album"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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 = [
|
||||||
|
('music', '0028_alter_track_albums'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='track',
|
||||||
|
name='run_time_seconds',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='track',
|
||||||
|
name='run_time_ticks',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='track',
|
||||||
|
name='base_run_time_seconds',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user