Compare commits
403 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d69d7311d5 | |||
| c484dab210 | |||
| 3d62a6a227 | |||
| 1a5d4a6717 | |||
| 8f0491a90b | |||
| 7e961076b4 | |||
| 14062f3b60 | |||
| 0a8acdf33f | |||
| b5d194e74f | |||
| 3a50a8b015 | |||
| 921cf9d8b3 | |||
| ba0be65ed0 | |||
| caad6329c9 | |||
| cfd6ac861e | |||
| ca36e25948 | |||
| c84acf6ae7 | |||
| 610464e732 | |||
| 047dd22069 | |||
| 2c73121367 | |||
| 9affd6e03a | |||
| b414bbf59c | |||
| 5e22cb3106 | |||
| cc9a2a64df | |||
| c2b334b926 | |||
| ad6f0e1b62 | |||
| 1df8157d8d | |||
| 7d33e6afdb | |||
| 40d112c58c | |||
| cdfd8af078 | |||
| 0ce19527a2 | |||
| f3fc58e2c0 | |||
| b02b75fa90 | |||
| b626ac583a | |||
| adc10ab43b | |||
| 5555b1b89e | |||
| 7fd90f8cf0 | |||
| f80daba67b | |||
| 8bd4fd1d4b | |||
| f30e416e8d | |||
| f29a039562 | |||
| 882367cee6 | |||
| 74c32bc4d5 | |||
| dcd7196010 | |||
| 2e57b2ce07 | |||
| 9d664ba476 | |||
| 0fa89af1d9 | |||
| 2c5516ae4e | |||
| 0cba46b103 | |||
| 5014c4428b | |||
| 1d554429f1 | |||
| daea268465 | |||
| 1cceb2bd63 | |||
| e15d253d58 | |||
| 8ed0bd3d21 | |||
| 113f200eb7 | |||
| 1b5ffd2a3c | |||
| f168608cee | |||
| c28f93a1bb | |||
| 99ad2f797f | |||
| 1f28179ed1 | |||
| 1648f988ff | |||
| dc3a77f14f | |||
| 5994dccf23 | |||
| bfadc0d87b | |||
| 78782cc538 | |||
| 06277f21de | |||
| 8fbe37a163 | |||
| 20dd4d217a | |||
| 53657a9454 | |||
| 42066cebb2 | |||
| 6d60a729b7 | |||
| 89541a13f2 | |||
| f87bc5fd55 | |||
| 0a8078dee0 | |||
| 37da74708c | |||
| b470e7acea | |||
| 5cdd12783f | |||
| 8bf43f298c | |||
| a269949a23 | |||
| 8a5f200b44 | |||
| 1c41ca3e18 | |||
| b69963b75f | |||
| 1327d5da40 | |||
| 5d5b49f19b | |||
| d93c827b80 | |||
| 2349c39487 | |||
| ae96831fe4 | |||
| 706be782dc | |||
| a72e0b0fb9 | |||
| f5df6c97a9 | |||
| e00f2de4b1 | |||
| b4a7cafa3d | |||
| aebe4f899d | |||
| 2bf0ca1a8c | |||
| a0c414135c | |||
| 1dcd151c65 | |||
| 2e9d92d9c7 | |||
| 8776f75d12 | |||
| c2a53875a0 | |||
| ffb0b7372d | |||
| d03744c240 | |||
| d9a7929cb0 | |||
| 9bc087d824 | |||
| e65ab4d8c2 | |||
| 2d26a7c3bd | |||
| 1fd3e47656 | |||
| dd0ce967c8 | |||
| 1f6a4956d2 | |||
| 22f5d288d4 | |||
| 77cf67ea09 | |||
| d1dfc5502d | |||
| 0d83050956 | |||
| 134bd3f650 | |||
| 261f2eb9ce | |||
| 8f56bfba50 | |||
| 750fa05b15 | |||
| 302ddf6650 | |||
| 49dd148f9f | |||
| 79d4e79f3e | |||
| 874d9dc7d2 | |||
| 0cd95036da | |||
| 829c6b9978 | |||
| df1bfd4177 | |||
| 0fff2ec388 | |||
| 8a83c9fdb3 | |||
| 651fc7a745 | |||
| c22a306840 | |||
| 11e6161502 | |||
| 3af2ad203c | |||
| 7697dd006d | |||
| 73a6277e29 | |||
| 79fc407c33 | |||
| 84ec84c018 | |||
| beb9569663 | |||
| efcf01b49f | |||
| 01270a9e32 | |||
| c9280c2bad | |||
| 416fd807f4 | |||
| 3069e93697 | |||
| e47f4905e8 | |||
| bf59fb213e | |||
| 589bf533ea | |||
| 1fb29304a3 | |||
| 42604b9e23 | |||
| 4642063510 | |||
| 65944eb911 | |||
| 4cf2ceb2dd | |||
| 78151e5070 | |||
| f90fa160d7 | |||
| 3b7498c419 | |||
| a59997dafb | |||
| 4cdb6150dc | |||
| 7fa21c27ea | |||
| b981c090c6 | |||
| 605435b9ea | |||
| f4d00b4a22 | |||
| 504620fc89 | |||
| df3424c68f | |||
| 71b5af7615 | |||
| 7035f01441 | |||
| 525c7c4e2b | |||
| 4e330a6f03 | |||
| ff18ad8efc | |||
| 15c1317395 | |||
| 6b80d107b0 | |||
| 13124aca6b | |||
| a685c6ff9c | |||
| 02200bb1f7 | |||
| 0bbd488f0d | |||
| 2c08336e33 | |||
| a637a59a40 | |||
| 123f7c53f9 | |||
| 2c26cc01ba | |||
| 36bc1c2e95 | |||
| 5651ccb990 | |||
| cd7b06eaa2 | |||
| 949416059d | |||
| ca434cb08a | |||
| 2661aee915 | |||
| 74f672a2fd | |||
| 449b74ae3f | |||
| 3d1ad8da3a | |||
| 75e374bb69 | |||
| 2ba694655b | |||
| 879942d070 | |||
| bfd210d280 | |||
| e5c6b5e8d9 | |||
| 3cd1603b91 | |||
| 27523bc7ff | |||
| 67ef1a15ec | |||
| 288027bfce | |||
| bfdf73d4c0 | |||
| 033e3c3b35 | |||
| 40504f83e1 | |||
| ccca81bbab | |||
| 84f49af163 | |||
| e044c70072 | |||
| d3e09c25bd | |||
| 30f78a9290 | |||
| a5553966de | |||
| 9e2d7a6bc0 | |||
| 70c7eda415 | |||
| 0ad7dac6cb | |||
| 209875f0e6 | |||
| 919fa1b0b4 | |||
| 0b3bc53704 | |||
| dfc1365fa3 | |||
| f22ef1a163 | |||
| 7cb818b585 | |||
| 1a5cd5106f | |||
| 3b65144b68 | |||
| 4ae13b3a1a | |||
| 3de2be50cf | |||
| 80d197bc54 | |||
| a274796405 | |||
| 37fd1d8458 | |||
| 45ddce36aa | |||
| c3ddd01a6f | |||
| 6920c99931 | |||
| d2d71b3c85 | |||
| 6300df8e9e | |||
| b427731bc3 | |||
| 6058024434 | |||
| a4d9bd607c | |||
| 9349009ffa | |||
| 2293c839e8 | |||
| 34fb2df782 | |||
| 3b2158d85c | |||
| 7daef6677d | |||
| d895cf9110 | |||
| dffb4f087c | |||
| 37c1aab749 | |||
| 9083e744da | |||
| af1b6cb4ed | |||
| de8727ee05 | |||
| fd645420b8 | |||
| 1fdad9ce5b | |||
| 81551ecb41 | |||
| 7a5b4c1b08 | |||
| c144f07f78 | |||
| b44af1f79d | |||
| 22f3b94448 | |||
| 36adf5a904 | |||
| c2f43861fd | |||
| 356579f78a | |||
| e5ed265e8f | |||
| 97d5478792 | |||
| 96ca2f5602 | |||
| 478780e9b3 | |||
| 9aaed4f332 | |||
| 4327ff499c | |||
| b53017d226 | |||
| 4e694bd1b3 | |||
| 7a9f4a0876 | |||
| 431976296f | |||
| 1e84497c85 | |||
| 05458b1fb9 | |||
| 93a514fc37 | |||
| c4c810e23f | |||
| d1de4103e1 | |||
| d0b298e4cc | |||
| 91ed364747 | |||
| e6f78f4b1d | |||
| f452ed748e | |||
| 63ddb51e89 | |||
| 61c10db0dd | |||
| 6696d38638 | |||
| 84351f8bcc | |||
| eb1a22e69f | |||
| 34ecb71c44 | |||
| 33dfbc2c2e | |||
| 8eb2526404 | |||
| 8e99918813 | |||
| ea894e1ebf | |||
| edcfbd829a | |||
| 4e1db4aa6f | |||
| f303115ca0 | |||
| 5928a07ef0 | |||
| dea34c926b | |||
| 7564292f5b | |||
| 5aa155094f | |||
| 635e8053cd | |||
| 0d3da63aaf | |||
| 1670a7cd7a | |||
| 0505d01b71 | |||
| aae2dd7567 | |||
| 88c252eb45 | |||
| abd07f3113 | |||
| 9ba9319885 | |||
| 22ad340d43 | |||
| b0574ecf80 | |||
| e9db212121 | |||
| d5d0325420 | |||
| 1bc5cf31e0 | |||
| 2e2d491e2e | |||
| c7d1272e99 | |||
| 7a6bd169bf | |||
| bbaa8fa078 | |||
| 21995c62fb | |||
| 880f810aa1 | |||
| 49e4e9b69a | |||
| b50778b43f | |||
| da944f9ef1 | |||
| f6509bbaa8 | |||
| f9aab6296b | |||
| 899129dfd9 | |||
| 3eb51a871e | |||
| ca3e495467 | |||
| 85a91b340a | |||
| e04be4fbdd | |||
| f9a98e9de9 | |||
| f18d81296b | |||
| e86d8cc9fe | |||
| 4ab796fe12 | |||
| fc64dfadba | |||
| a5d729d26a | |||
| a661135394 | |||
| f1b1989424 | |||
| 145212fe46 | |||
| 607c2522f3 | |||
| 8646fd6881 | |||
| 63b783229c | |||
| b7e15da87a | |||
| 8a597ef1b0 | |||
| ca65ce11f2 | |||
| ad2f28c214 | |||
| e517327ce3 | |||
| ff5a88cb17 | |||
| 1fedf77b87 | |||
| 837aa53c9b | |||
| 9e38798f44 | |||
| 6d841167ea | |||
| 6e97298949 | |||
| 40fc4d21d3 | |||
| 9eeb708a51 | |||
| 35214de127 | |||
| 0adc97f440 | |||
| 0140126ed8 | |||
| f7b8bc3bfc | |||
| 7e16388f81 | |||
| 6dc09e723d | |||
| 1d50c32ba9 | |||
| 0bf4d28482 | |||
| bcf2b9d1ea | |||
| a3bf9b0081 | |||
| 1e35f10945 | |||
| 955b6f028a | |||
| cc751d0953 | |||
| a7e8e4f1dc | |||
| f55aed7b3d | |||
| 79265feb39 | |||
| 1ba5b4cf4b | |||
| 39add993e0 | |||
| 4fd8a5b9a5 | |||
| dad936cd6b | |||
| 704409cf6e | |||
| 6ee57cb7cd | |||
| 95da4c063e | |||
| 93de6d1556 | |||
| dbbb2b43a8 | |||
| 7758c56d10 | |||
| 6c56cfab85 | |||
| 6753c3717f | |||
| 085c666c19 | |||
| dca66003fd | |||
| dc55c538ab | |||
| bb69be4817 | |||
| af0223ab4c | |||
| 9ed5d5dc1a | |||
| dedf28c6de | |||
| 3607d4d4a4 | |||
| 6bdb7b6e1f | |||
| c864f408a1 | |||
| f1c22bfbc0 | |||
| 88bd049d95 | |||
| 8f4ce4441b | |||
| bee9ac8d25 | |||
| 61c9e362a8 | |||
| efde57078e | |||
| fc927c72d1 | |||
| 5c3a554010 | |||
| f21650505c | |||
| 0217c96faf | |||
| 4654902adc | |||
| 954b35b1d0 | |||
| 620f52f9ef | |||
| e495a08d1f | |||
| 74ce5ec9ab | |||
| fb107c083f | |||
| f9dcc0d341 | |||
| 9eef5d721b | |||
| 6d613028fc | |||
| 7db98f0979 | |||
| 4e38605008 | |||
| 7106a0840d | |||
| 1f7846f096 | |||
| 010565efb7 | |||
| 9baf1069b6 | |||
| 2896225826 | |||
| be6b3c5e2e | |||
| e487f50683 | |||
| 8042c726b0 | |||
| 59e29d858a |
31
.drone.yml
31
.drone.yml
@ -4,18 +4,17 @@
|
||||
################
|
||||
|
||||
kind: pipeline
|
||||
name: run_tests
|
||||
name: build & deploy
|
||||
|
||||
steps:
|
||||
# Run tests against Python/Flask engine backend (with pytest)
|
||||
- name: pytest with coverage
|
||||
image: python:3.10.4
|
||||
image: python:3.11.1
|
||||
commands:
|
||||
# Install dependencies
|
||||
- cp vrobbler.conf.test vrobbler.conf
|
||||
- pip install poetry
|
||||
- poetry config repositories.unblink "https://pypi.unbl.ink/pypi/simple"
|
||||
- poetry install
|
||||
- poetry install --with dev
|
||||
# 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
|
||||
environment:
|
||||
@ -31,7 +30,7 @@ steps:
|
||||
- vrobbler.service
|
||||
username: root
|
||||
ssh_key:
|
||||
from_secret: ssh_key
|
||||
from_secret: jail_key
|
||||
command_timeout: 2m
|
||||
script:
|
||||
- pip uninstall -y vrobbler
|
||||
@ -42,6 +41,28 @@ steps:
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
- name: build success notification
|
||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||
when:
|
||||
status: [success]
|
||||
settings:
|
||||
url: https://ntfy.unbl.ink
|
||||
topic: drone
|
||||
priority: low
|
||||
tags:
|
||||
- failure
|
||||
- vrobbler
|
||||
- name: build failure notification
|
||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||
when:
|
||||
status: [failure]
|
||||
settings:
|
||||
url: https://ntfy.unbl.ink
|
||||
topic: drone
|
||||
priority: high
|
||||
tags:
|
||||
- success
|
||||
- vrobbler
|
||||
volumes:
|
||||
- name: docker
|
||||
host:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
db.sqlite3
|
||||
db.sqlite3*
|
||||
vrobbler.conf
|
||||
media/
|
||||
dist/
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
Vrobbler
|
||||
========
|
||||
|
||||
[](https://ci.unbl.ink/secstate/vrobbler)
|
||||
[](https://ci.lab.unbl.ink/secstate/vrobbler)
|
||||
|
||||
Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation.
|
||||
|
||||
|
||||
3
data/moods.json
Normal file
3
data/moods.json
Normal file
File diff suppressed because one or more lines are too long
5763
poetry.lock
generated
5763
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,11 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.11.12"
|
||||
version = "0.15.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
python = ">=3.9,<4.0"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
@ -40,8 +40,17 @@ beautifulsoup4 = "^4.11.2"
|
||||
django-storages = "^1.13.2"
|
||||
boto3 = "^1.26.98"
|
||||
stream-sqlite = "^0.0.41"
|
||||
ipython = "^8.14.0"
|
||||
pendulum = "^2.1.2"
|
||||
trafilatura = "^1.6.3"
|
||||
django-imagekit = "^5.0.0"
|
||||
thefuzz = "^0.22.1"
|
||||
dataclass-wizard = "0.22.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
Werkzeug = "2.0.3"
|
||||
black = "^22.3"
|
||||
coverage = "^7.0.5"
|
||||
@ -53,7 +62,6 @@ pytest-django = "^4.5.2"
|
||||
pytest-flake8 = "^1.1"
|
||||
pytest-isort = "^3.0"
|
||||
pytest-runner = "^6.0"
|
||||
pytest-selenium = "^2.0.1"
|
||||
time-machine = "^2.9.0"
|
||||
types-pytz = "^2022.1"
|
||||
types-requests = "^2.27"
|
||||
@ -61,9 +69,9 @@ bandit = "^1.7.4"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
addopts = "-ra -q"
|
||||
addopts = "-ra -q --reuse-db"
|
||||
testpaths = ["tests"]
|
||||
DJANGO_SETTINGS_MODULE='vrobbler.settings'
|
||||
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
||||
|
||||
[tool.black]
|
||||
line-length = 79
|
||||
|
||||
27
tests/boardgames_tests/test_bgg.py
Normal file
27
tests/boardgames_tests/test_bgg.py
Normal file
@ -0,0 +1,27 @@
|
||||
from boardgames.bgg import (
|
||||
take_first,
|
||||
lookup_boardgame_id_from_bgg,
|
||||
lookup_boardgame_from_bgg,
|
||||
)
|
||||
|
||||
|
||||
def test_take_first():
|
||||
assert take_first([]) == ""
|
||||
|
||||
assert take_first(["a", "b"]) == "a"
|
||||
|
||||
|
||||
def test_lookup_boardgame_id_from_bgg():
|
||||
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
||||
assert bgg_id == "15"
|
||||
|
||||
bgg_id = lookup_boardgame_id_from_bgg("Comedy Encounter")
|
||||
assert bgg_id == None
|
||||
|
||||
|
||||
def test_lookup_boardgame_from_bgg():
|
||||
bgg_result = lookup_boardgame_from_bgg(15)
|
||||
assert bgg_result.get("bggeek_id") == 15
|
||||
|
||||
bgg_result = lookup_boardgame_from_bgg("Cosmic Encounter")
|
||||
assert bgg_result.get("bggeek_id") == "15"
|
||||
@ -1,28 +1,21 @@
|
||||
import pytest
|
||||
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
|
||||
|
||||
expected_desc = (
|
||||
expected_desc_snippet = (
|
||||
"NPR's Up First is the news you need to start your day. "
|
||||
"The three biggest stories of the day, with reporting and analysis "
|
||||
"from NPR News — in 10 minutes. Available weekdays by 6 a.m. ET, "
|
||||
"with hosts Leila Fadel, Steve Inskeep, Michel Martin and A Martinez. "
|
||||
"Also available on Saturdays by 8 a.m. ET, with Ayesha Rascoe and "
|
||||
"Scott Simon. On Sundays, hear a longer exploration behind the "
|
||||
"headlines with Rachel Martin, available by 8 a.m. ET. Subscribe "
|
||||
"and listen, then support your local NPR station at donate.npr.org. "
|
||||
"Support NPR's reporting by subscribing to Up First+ and unlock "
|
||||
"sponsor-free listening. Learn more at plus.npr.org/UpFirst"
|
||||
)
|
||||
|
||||
expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcT-PqtK-bauo8wm8dBE__SVGArlvfBYY8rqxr2kA5UwjKzEx8c"
|
||||
expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0CfR24RR6sme531yIkCrnK4zzmo97jeualO5drVPKG6oCk"
|
||||
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
|
||||
|
||||
|
||||
@pytest.mark.skip("Google Podcasts is gone")
|
||||
def test_get_not_allowed_from_mopidy():
|
||||
query = "Up First"
|
||||
result_dict = scrape_data_from_google_podcasts(query)
|
||||
|
||||
assert result_dict["title"] == query
|
||||
assert result_dict["description"] == expected_desc
|
||||
assert expected_desc_snippet in result_dict["description"]
|
||||
assert result_dict["image_url"] == expected_img_url
|
||||
assert result_dict["producer"] == "NPR"
|
||||
assert result_dict["google_url"] == expected_google_url
|
||||
|
||||
@ -1,19 +1,39 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
from boardgames.models import BoardGame
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boardgame_scrobble():
|
||||
user = User.objects.create(
|
||||
email="test@exmaple.com", first_name="Test", last_name="User"
|
||||
)
|
||||
return Scrobble.objects.create(
|
||||
board_game=BoardGame.objects.create(title="Test Board Game"),
|
||||
media_type="BoardGame",
|
||||
played_to_completion=True,
|
||||
log={
|
||||
"players": [
|
||||
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class MopidyRequest:
|
||||
name = "Same in the End"
|
||||
artist = "Sublime"
|
||||
album = "Sublime"
|
||||
track_number = 4
|
||||
run_time_ticks = 156604
|
||||
run_time = "156"
|
||||
run_time = 60
|
||||
playback_time_ticks = 15045
|
||||
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
|
||||
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||
@ -65,8 +85,8 @@ def valid_auth_token():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_track_request_data():
|
||||
return MopidyRequest().request_json
|
||||
def mopidy_track():
|
||||
return MopidyRequest()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -78,6 +98,61 @@ def mopidy_track_diff_album_request_data(**kwargs):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_podcast_request_data():
|
||||
def mopidy_podcast():
|
||||
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
|
||||
return MopidyRequest(mopidy_uri=mopidy_uri).request_json
|
||||
return MopidyRequest(mopidy_uri=mopidy_uri)
|
||||
|
||||
|
||||
class JellyfinTrackRequest:
|
||||
name = "Emotion"
|
||||
artist = "Carly Rae Jepsen"
|
||||
album = "Emotion"
|
||||
track_number = 1
|
||||
item_type = "Audio"
|
||||
timestamp = "2024-01-14 12:00:19"
|
||||
run_time_ticks = 156604
|
||||
run_time = "00:00:60"
|
||||
playback_time_ticks = 15045
|
||||
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
|
||||
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
||||
status = "resumed"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.request_data = {
|
||||
"Name": kwargs.get("name", self.name),
|
||||
"Artist": kwargs.get("artist", self.artist),
|
||||
"Album": kwargs.get("album", self.album),
|
||||
"TrackNumber": int(kwargs.get("track_number", self.track_number)),
|
||||
"RunTime": kwargs.get("run_time", self.run_time),
|
||||
"ItemType": kwargs.get("item_type", self.item_type),
|
||||
"UtcTimestamp": kwargs.get("timestamp", self.timestamp),
|
||||
"PlaybackPositionTicks": int(
|
||||
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
||||
),
|
||||
"Provider_musicbrainztrack": kwargs.get(
|
||||
"musicbrainz_track_id", self.musicbrainz_track_id
|
||||
),
|
||||
"Provider_musicbrainzalbum": kwargs.get(
|
||||
"musicbrainz_album_id", self.musicbrainz_album_id
|
||||
),
|
||||
"Provider_musicbrainzartist": kwargs.get(
|
||||
"musicbrainz_artist_id", self.musicbrainz_artist_id
|
||||
),
|
||||
"Status": kwargs.get("status", self.status),
|
||||
}
|
||||
|
||||
def __eq__(self, other):
|
||||
for key in self.request_data.keys():
|
||||
if self.request_data[key] != getattr(self, key):
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def request_json(self):
|
||||
return json.dumps(self.request_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jellyfin_track():
|
||||
return JellyfinTrackRequest()
|
||||
|
||||
@ -6,16 +6,18 @@ from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
|
||||
from music.models import Album, Artist
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def build_scrobbles(client, request_data, num=7, spacing=2):
|
||||
def build_scrobbles(client, request_json, num=7, spacing=2):
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
user = get_user_model().objects.create(username="Test User")
|
||||
UserProfile.objects.create(user=user, timezone="US/Eastern")
|
||||
user.profile.timezone = "US/Eastern"
|
||||
user.profile.save()
|
||||
for i in range(num):
|
||||
client.post(url, request_data, content_type="application/json")
|
||||
client.post(url, request_json, content_type="application/json")
|
||||
s = Scrobble.objects.last()
|
||||
s.user = user
|
||||
s.timestamp = timezone.now() - timedelta(days=i * spacing)
|
||||
@ -25,8 +27,8 @@ def build_scrobbles(client, request_data, num=7, spacing=2):
|
||||
|
||||
@pytest.mark.django_db
|
||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||
def test_scrobble_counts_data(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data)
|
||||
def test_scrobble_counts_data(client, mopidy_track):
|
||||
build_scrobbles(client, mopidy_track.request_json)
|
||||
user = get_user_model().objects.first()
|
||||
count_dict = scrobble_counts(user)
|
||||
assert count_dict == {
|
||||
@ -39,64 +41,31 @@ def test_scrobble_counts_data(client, mopidy_track_request_data):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_week_of_scrobbles_data(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||
def test_live_charts(client, mopidy_track):
|
||||
build_scrobbles(client, mopidy_track.request_json, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
|
||||
week = week_of_scrobbles(user)
|
||||
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_tracks_by_day(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = live_charts(user)
|
||||
assert tops[0].title == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_tracks_by_week(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = live_charts(user, chart_period="week")
|
||||
assert tops[0].title == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_tracks_by_month(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = live_charts(user, chart_period="month")
|
||||
assert tops[0].title == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_tracks_by_year(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = live_charts(user, chart_period="year")
|
||||
assert tops[0].title == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top__artists_by_week(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = live_charts(user, chart_period="week", media_type="Artist")
|
||||
assert tops[0].name == "Sublime"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top__artists_by_month(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = live_charts(user, chart_period="month", media_type="Artist")
|
||||
assert tops[0].name == "Sublime"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top__artists_by_year(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = live_charts(user, chart_period="year", media_type="Artist")
|
||||
assert tops[0].name == "Sublime"
|
||||
|
||||
31
tests/scrobbles_tests/test_metadata.py
Normal file
31
tests/scrobbles_tests/test_metadata.py
Normal file
@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
|
||||
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_boardgame_log_data(boardgame_scrobble):
|
||||
assert not boardgame_scrobble.geo_location
|
||||
assert boardgame_scrobble.logdata == BoardGameLogData(
|
||||
players=[
|
||||
BoardGameScoreLogData(
|
||||
user_id=1,
|
||||
name_str="",
|
||||
bgg_username="",
|
||||
color="Blue",
|
||||
character=None,
|
||||
team=None,
|
||||
score=30,
|
||||
win=True,
|
||||
new=None,
|
||||
)
|
||||
],
|
||||
location=None,
|
||||
geo_location_id=None,
|
||||
difficulty=None,
|
||||
solo=None,
|
||||
two_handed=None,
|
||||
)
|
||||
assert len(boardgame_scrobble.logdata.players) == 1
|
||||
assert boardgame_scrobble.logdata.players[0].user.id == 1
|
||||
assert boardgame_scrobble.logdata.players[0].name == "Test"
|
||||
12
tests/scrobbles_tests/test_utils.py
Normal file
12
tests/scrobbles_tests/test_utils.py
Normal file
@ -0,0 +1,12 @@
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
|
||||
def test_timestamp_user_tz_to_utc():
|
||||
timestamp = timestamp_user_tz_to_utc(
|
||||
1685561082, pytz.timezone("US/Eastern")
|
||||
)
|
||||
assert timestamp == datetime(2023, 5, 31, 23, 24, 42, tzinfo=pytz.utc)
|
||||
@ -1,7 +1,12 @@
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from django.urls import reverse
|
||||
from music.models import Track
|
||||
from podcasts.models import Episode
|
||||
from podcasts.models import PodcastEpisode
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
@ -25,31 +30,57 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"seconds, expected_percent_played, expected_scrobble_id",
|
||||
[
|
||||
(1, 1, 1),
|
||||
(58, 96, 1),
|
||||
(59, 98, 1),
|
||||
(60, 100, 1),
|
||||
(1, 1, 2),
|
||||
(1, 1, 3),
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_mopidy_track(
|
||||
client, mopidy_track_request_data, valid_auth_token
|
||||
client,
|
||||
mopidy_track,
|
||||
valid_auth_token,
|
||||
seconds,
|
||||
expected_percent_played,
|
||||
expected_scrobble_id,
|
||||
):
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track_request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
assert scrobble.media_obj.title == "Same in the End"
|
||||
# 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="API is unstable")
|
||||
@pytest.mark.skip(reason="Allmusic API is unstable")
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_mopidy_same_track_different_album(
|
||||
client,
|
||||
mopidy_track_request_data,
|
||||
mopidy_track,
|
||||
mopidy_track_diff_album_request_data,
|
||||
valid_auth_token,
|
||||
):
|
||||
@ -57,7 +88,7 @@ def test_scrobble_mopidy_same_track_different_album(
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track_request_data,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
@ -96,5 +127,85 @@ def test_scrobble_mopidy_podcast(
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Episode
|
||||
assert scrobble.media_obj.__class__ == PodcastEpisode
|
||||
assert scrobble.media_obj.title == "Up First"
|
||||
|
||||
|
||||
@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(
|
||||
mock_lookup_artist,
|
||||
mock_lookup_album,
|
||||
mock_lookup_track,
|
||||
mock_lookup_artist_tadb,
|
||||
mock_lookup_album_tadb,
|
||||
mock_fetch_artwork,
|
||||
mock_scrape_allmusic,
|
||||
client,
|
||||
jellyfin_track,
|
||||
valid_auth_token,
|
||||
):
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
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
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 1, 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"
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
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]
|
||||
|
||||
@ -149,6 +153,7 @@ 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
|
||||
@ -440,7 +445,5 @@ has to re-populate when the server restarts.
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
** TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
|
||||
** TODO What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
|
||||
** 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:
|
||||
|
||||
@ -20,6 +20,7 @@ VROBBLER_THESPORTSDB_API_KEY="<key>"
|
||||
VROBBLER_THEAUDIODB_API_KEY="<key>"
|
||||
VROBBLER_IGDB_CLIENT_ID="<id>"
|
||||
VROBBLER_IGDB_CLIENT_SECRET="<key>"
|
||||
VROBBLER_COMICVINE_API_KEY="<key>"
|
||||
|
||||
# Storages
|
||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
# Local configuration for Emus
|
||||
|
||||
VROBBLER_DUMP_REQUEST_DATA=True
|
||||
VROBBLER_LOG_TO_CONSOLE=True
|
||||
VROBBLER_DEBUG=True
|
||||
VROBBLER_DUMP_REQUEST_DATA=False
|
||||
VROBBLER_LOG_TO_CONSOLE=False
|
||||
VROBBLER_DEBUG=False
|
||||
VROBBLER_LOG_LEVEL="DEBUG"
|
||||
VROBBLER_MEDIA_ROOT = "/tmp/media/"
|
||||
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=False
|
||||
|
||||
VROBBLER_USE_S3="False"
|
||||
VROBBLER_DATABASE_URL="sqlite:///testdb.sqlite3"
|
||||
|
||||
30
vrobbler/apps/boardgames/admin.py
Normal file
30
vrobbler/apps/boardgames/admin.py
Normal file
@ -0,0 +1,30 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from boardgames.models import BoardGame, BoardGamePublisher
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(BoardGamePublisher)
|
||||
class BoardGamePublisherAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"uuid",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGame)
|
||||
class GameAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"bggeek_id",
|
||||
"title",
|
||||
"published_date",
|
||||
)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
92
vrobbler/apps/boardgames/bgg.py
Normal file
92
vrobbler/apps/boardgames/bgg.py
Normal file
@ -0,0 +1,92 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEARCH_ID_URL = (
|
||||
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||
)
|
||||
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
|
||||
|
||||
|
||||
def take_first(thing: Optional[list]) -> str:
|
||||
first = ""
|
||||
try:
|
||||
first = thing[0]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
if first:
|
||||
try:
|
||||
first = first.get_text()
|
||||
except:
|
||||
pass
|
||||
|
||||
return first
|
||||
|
||||
|
||||
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||
soup = None
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
game_id = None
|
||||
url = SEARCH_ID_URL.format(query=title)
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
if soup:
|
||||
result = soup.findAll("boardgame")
|
||||
if not result:
|
||||
return game_id
|
||||
|
||||
game_id = result[0].get("objectid", None)
|
||||
|
||||
return game_id
|
||||
|
||||
|
||||
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
soup = None
|
||||
game_dict = {}
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
|
||||
title = ""
|
||||
bgg_id = None
|
||||
|
||||
try:
|
||||
bgg_id = int(lookup_id)
|
||||
logger.debug(f"Using BGG ID {bgg_id} to find board game")
|
||||
except ValueError:
|
||||
title = lookup_id
|
||||
logger.debug(f"Using title {title} to find board game")
|
||||
|
||||
if not bgg_id:
|
||||
bgg_id = lookup_boardgame_id_from_bgg(title)
|
||||
|
||||
url = GAME_ID_URL.format(id=bgg_id)
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
if soup:
|
||||
seconds_to_play = None
|
||||
minutes = take_first(soup.findAll("playingtime"))
|
||||
if minutes:
|
||||
seconds_to_play = int(minutes) * 60
|
||||
|
||||
game_dict = {
|
||||
"bggeek_id": bgg_id,
|
||||
"title": take_first(soup.findAll("name", primary="true")),
|
||||
"description": take_first(soup.findAll("description")),
|
||||
"year_published": take_first(soup.findAll("yearpublished")),
|
||||
"publisher_name": take_first(soup.findAll("boardgamepublisher")),
|
||||
"cover_url": take_first(soup.findAll("image")),
|
||||
"min_players": take_first(soup.findAll("minplayers")),
|
||||
"max_players": take_first(soup.findAll("maxplayers")),
|
||||
"recommended_age": take_first(soup.findAll("age")),
|
||||
"run_time_seconds": seconds_to_play,
|
||||
}
|
||||
|
||||
return game_dict
|
||||
168
vrobbler/apps/boardgames/migrations/0001_initial.py
Normal file
168
vrobbler/apps/boardgames/migrations/0001_initial.py
Normal file
@ -0,0 +1,168 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-17 22:11
|
||||
|
||||
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", "0038_alter_objectwithgenres_tag"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BoardGamePublisher",
|
||||
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,
|
||||
),
|
||||
),
|
||||
(
|
||||
"logo",
|
||||
models.ImageField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to="games/platform-logos/",
|
||||
),
|
||||
),
|
||||
("igdb_id", models.IntegerField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BoardGame",
|
||||
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"
|
||||
),
|
||||
),
|
||||
(
|
||||
"run_time_seconds",
|
||||
models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"run_time_ticks",
|
||||
models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"cover",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="boardgames/covers/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"layout_image",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="boardgames/layouts/"
|
||||
),
|
||||
),
|
||||
("summary", models.TextField(blank=True, null=True)),
|
||||
("rating", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"max_players",
|
||||
models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"min_players",
|
||||
models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
("published_date", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"recommened_age",
|
||||
models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"seconds_to_play",
|
||||
models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"bggeek_id",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"genre",
|
||||
taggit.managers.TaggableManager(
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
(
|
||||
"publisher",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="boardgames.boardgamepublisher",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-17 22:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-17 22:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0002_boardgame_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="boardgame",
|
||||
old_name="recommened_age",
|
||||
new_name="recommended_age",
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-17 22:25
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0003_rename_recommened_age_boardgame_recommended_age"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="boardgame",
|
||||
name="seconds_to_play",
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-17 22:29
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0004_remove_boardgame_seconds_to_play"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="boardgame",
|
||||
name="summary",
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-18 02:33
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0040_alter_scrobble_media_type"),
|
||||
("boardgames", "0005_remove_boardgame_summary"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/boardgames/migrations/__init__.py
Normal file
0
vrobbler/apps/boardgames/migrations/__init__.py
Normal file
170
vrobbler/apps/boardgames/models.py
Normal file
170
vrobbler/apps/boardgames/models.py
Normal file
@ -0,0 +1,170 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from boardgames.bgg import lookup_boardgame_from_bgg
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
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 BoardGameLogData
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class BoardGamePublisher(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
|
||||
igdb_id = models.IntegerField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:publisher_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
|
||||
class BoardGame(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(
|
||||
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
||||
)
|
||||
|
||||
FIELDS_FROM_BGGEEK = [
|
||||
"igdb_id",
|
||||
"alternative_name",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"release_date",
|
||||
"cover",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
publisher = models.ForeignKey(
|
||||
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
|
||||
cover_small = ImageSpecField(
|
||||
source="cover",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
cover_medium = ImageSpecField(
|
||||
source="cover",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
layout_image = models.ImageField(upload_to="boardgames/layouts/", **BNULL)
|
||||
layout_image_small = ImageSpecField(
|
||||
source="layout_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
layout_image_medium = ImageSpecField(
|
||||
source="layout_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
rating = models.FloatField(**BNULL)
|
||||
max_players = models.PositiveSmallIntegerField(**BNULL)
|
||||
min_players = models.PositiveSmallIntegerField(**BNULL)
|
||||
published_date = models.DateField(**BNULL)
|
||||
recommended_age = models.PositiveSmallIntegerField(**BNULL)
|
||||
bggeek_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BoardGameLogData
|
||||
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.cover:
|
||||
url = self.cover.url
|
||||
return url
|
||||
|
||||
def bggeek_link(self):
|
||||
link = ""
|
||||
if self.bggeek_id:
|
||||
link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
|
||||
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:
|
||||
|
||||
if not self.published_date or force_update:
|
||||
|
||||
if not data:
|
||||
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
||||
|
||||
cover_url = data.pop("cover_url")
|
||||
year = data.pop("year_published")
|
||||
publisher_name = data.pop("publisher_name")
|
||||
|
||||
if year:
|
||||
data["published_date"] = datetime(int(year), 1, 1)
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
BoardGame.objects.filter(pk=self.id).update(**data)
|
||||
self.refresh_from_db()
|
||||
|
||||
# Add publishers
|
||||
(
|
||||
self.publisher,
|
||||
_created,
|
||||
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
|
||||
self.save()
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not self.cover:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
logger.debug(r.status_code)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
logger.debug("Loaded cover image from BGGeek")
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, lookup_id: str, data: Optional[dict] = {}
|
||||
) -> Optional["BoardGame"]:
|
||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||
boardgame = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
|
||||
if not data or not boardgame:
|
||||
data = lookup_boardgame_from_bgg(lookup_id)
|
||||
|
||||
if data and not boardgame:
|
||||
boardgame, created = cls.objects.get_or_create(
|
||||
title=data["title"], bggeek_id=lookup_id
|
||||
)
|
||||
if created:
|
||||
boardgame.fix_metadata(data=data)
|
||||
|
||||
return boardgame
|
||||
21
vrobbler/apps/boardgames/urls.py
Normal file
21
vrobbler/apps/boardgames/urls.py
Normal file
@ -0,0 +1,21 @@
|
||||
from django.urls import path
|
||||
from boardgames import views
|
||||
|
||||
app_name = "boardgames"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"board-game/", views.BoardGameListView.as_view(), name="boardgame_list"
|
||||
),
|
||||
path(
|
||||
"board-game/<slug:slug>/",
|
||||
views.BoardGameDetailView.as_view(),
|
||||
name="boardgame_detail",
|
||||
),
|
||||
path(
|
||||
"board-game-publisher/<slug:slug>/",
|
||||
views.BoardGamePublisherDetailView.as_view(),
|
||||
name="publisher_detail",
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/boardgames/utils.py
Normal file
0
vrobbler/apps/boardgames/utils.py
Normal file
17
vrobbler/apps/boardgames/views.py
Normal file
17
vrobbler/apps/boardgames/views.py
Normal file
@ -0,0 +1,17 @@
|
||||
from django.views import generic
|
||||
from boardgames.models import BoardGame, BoardGamePublisher
|
||||
|
||||
|
||||
class BoardGameListView(generic.ListView):
|
||||
model = BoardGame
|
||||
paginate_by = 20
|
||||
|
||||
|
||||
class BoardGameDetailView(generic.DetailView):
|
||||
model = BoardGame
|
||||
slug_field = "uuid"
|
||||
|
||||
|
||||
class BoardGamePublisherDetailView(generic.DetailView):
|
||||
model = BoardGamePublisher
|
||||
slug_field = "uuid"
|
||||
142
vrobbler/apps/books/amazon.py
Normal file
142
vrobbler/apps/books/amazon.py
Normal file
@ -0,0 +1,142 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
|
||||
)
|
||||
AMAZON_SEARCH_URL = "https://www.amazon.com/s?k={amazon_id}"
|
||||
|
||||
|
||||
class AmazonAttribute(Enum):
|
||||
SERIES = 0
|
||||
PAGES = 1
|
||||
LANGUAGE = 2
|
||||
PUBLISHER = 3
|
||||
PUB_DATE = 4
|
||||
DIMENSIONS = 5
|
||||
ISBN_10 = 6
|
||||
ISBN_13 = 7
|
||||
|
||||
|
||||
def strip_and_clean(text):
|
||||
return text.strip("\n").rstrip().lstrip()
|
||||
|
||||
|
||||
def get_rating_from_soup(soup) -> Optional[int]:
|
||||
rating = None
|
||||
try:
|
||||
potential_rating = soup.find("div", class_="allmusic-rating")
|
||||
if potential_rating:
|
||||
rating = int(strip_and_clean(potential_rating.get_text()))
|
||||
except ValueError:
|
||||
pass
|
||||
return rating
|
||||
|
||||
|
||||
def get_review_from_soup(soup) -> str:
|
||||
review = ""
|
||||
try:
|
||||
potential_text = soup.find("div", class_="text")
|
||||
if potential_text:
|
||||
review = strip_and_clean(potential_text.get_text())
|
||||
except ValueError:
|
||||
pass
|
||||
return review
|
||||
|
||||
|
||||
def scrape_data_from_amazon(url) -> dict:
|
||||
data_dict = {}
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
# TODO Fix this scraper
|
||||
data_dict["rating"] = get_rating_from_soup(soup)
|
||||
data_dict["review"] = get_review_from_soup(soup)
|
||||
return data_dict
|
||||
|
||||
|
||||
def get_amazon_product_dict(amazon_id: str) -> dict:
|
||||
data_dict = {}
|
||||
url = ""
|
||||
|
||||
search_url = AMAZON_SEARCH_URL.format(amazon_id=amazon_id)
|
||||
headers = {
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
"accept-language": "en-GB,en;q=0.9",
|
||||
}
|
||||
|
||||
response = requests.get(search_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.info(f"Bad http response from Amazon {response}")
|
||||
return data_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
results = soup.find("a", class_="a-link-normal")
|
||||
|
||||
if not results:
|
||||
logger.info(f"No search results for {amazon_id}")
|
||||
return data_dict
|
||||
|
||||
product_url = "https://www.amazon.com" + str(results.get("href", ""))
|
||||
|
||||
data_dict = {}
|
||||
response = requests.get(product_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.info(f"Bad http response from Amazon {response}")
|
||||
return data_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
try:
|
||||
data_dict["title"] = soup.findAll("span", class_="celwidget")[
|
||||
1
|
||||
].text.strip()
|
||||
data_dict["cover_url"] = soup.find("img", class_="frontImage").get(
|
||||
"src"
|
||||
)
|
||||
data_dict["summary"] = soup.findAll(
|
||||
"div", class_="a-expander-content"
|
||||
)[1].text
|
||||
meta = soup.findAll("div", class_="rpi-attribute-value")
|
||||
data_dict["isbn"] = meta[AmazonAttribute.ISBN_10.value].text.strip()
|
||||
pages = meta[AmazonAttribute.PAGES.value].text
|
||||
if "pages" in pages:
|
||||
data_dict["pages"] = (
|
||||
meta[AmazonAttribute.PAGES.value]
|
||||
.text.split("pages")[0]
|
||||
.strip()
|
||||
)
|
||||
except IndexError as e:
|
||||
logger.error(
|
||||
f"Amazon lookup is failing for this product {amazon_id}: {e}"
|
||||
)
|
||||
except AttributeError as e:
|
||||
logger.error(
|
||||
f"Amazon lookup is failing for this product {amazon_id}: {e}"
|
||||
)
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
def lookup_book_from_amazon(amazon_id: str) -> dict:
|
||||
top = {}
|
||||
|
||||
return {
|
||||
"title": top.get("title"),
|
||||
"isbn": isbn,
|
||||
"openlibrary_id": ol_id,
|
||||
"goodreads_id": get_first("id_goodreads", top),
|
||||
"first_publish_year": top.get("first_publish_year"),
|
||||
"first_sentence": first_sentence,
|
||||
"pages": top.get("number_of_pages_median", None),
|
||||
"cover_url": COVER_URL.format(id=ol_id),
|
||||
"ol_author_id": ol_author_id,
|
||||
"subject_key_list": top.get("subject_key", []),
|
||||
}
|
||||
233
vrobbler/apps/books/comicvine.py
Normal file
233
vrobbler/apps/books/comicvine.py
Normal file
@ -0,0 +1,233 @@
|
||||
"""
|
||||
ComicVine API Information & Documentation:
|
||||
https://comicvine.gamespot.com/api/
|
||||
https://comicvine.gamespot.com/api/documentation
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ComicVineClient(object):
|
||||
"""
|
||||
Interacts with the ``search`` resource of the ComicVine API. Requires an
|
||||
account on https://comicvine.gamespot.com/ in order to obtain an API key.
|
||||
"""
|
||||
|
||||
# All API requests made by this client will be made to this URL.
|
||||
API_URL = "https://www.comicvine.com/api/search/"
|
||||
|
||||
# A valid User-Agent header must be set in order for our API requests to
|
||||
# be accepted, otherwise our request will be rejected with a
|
||||
# **403 - Forbidden** error.
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:7.0) "
|
||||
"Gecko/20130825 Firefox/36.0"
|
||||
}
|
||||
|
||||
# A set of valid resource types to return in results.
|
||||
RESOURCE_TYPES = {
|
||||
"character",
|
||||
"issue",
|
||||
"location",
|
||||
"object",
|
||||
"person",
|
||||
"publisher",
|
||||
"story_arc",
|
||||
"team",
|
||||
"volume",
|
||||
}
|
||||
|
||||
def __init__(self, api_key, expire_after=300):
|
||||
"""
|
||||
Store the API key in a class variable, and install the requests cache,
|
||||
configuring it using the ``expire_after`` parameter.
|
||||
|
||||
:param api_key: Your personal ComicVine API key.
|
||||
:type api_key: str
|
||||
:param expire_after: The number of seconds to retain an entry in cache.
|
||||
:type expire_after: int or None
|
||||
"""
|
||||
|
||||
self.api_key = api_key
|
||||
|
||||
def search(self, query, offset=0, limit=10, resources=None):
|
||||
"""
|
||||
Perform a search against the API, using the provided query term. If
|
||||
required, a list of resource types to filter search results to can
|
||||
be included.
|
||||
|
||||
Take the JSON contained in the response and provide it to the custom
|
||||
``Response`` object's constructor. Return the ``Response`` object.
|
||||
|
||||
:param query: The search query with which to make the request.
|
||||
:type query: str
|
||||
:param offset: The index of the first record returned.
|
||||
:type offset: int or None
|
||||
:param limit: How many records to return **(max 10)**
|
||||
:type limit: int or None
|
||||
:param resources: A list of resources to include in the search results.
|
||||
:type resources: list or None
|
||||
:type use_cache: bool
|
||||
|
||||
:return: The response object containing the results of the search
|
||||
query.
|
||||
:rtype: comicvine_search.response.Response
|
||||
"""
|
||||
|
||||
params = self._request_params(query, offset, limit, resources)
|
||||
json_data = self._query_api(params)
|
||||
|
||||
return json_data
|
||||
|
||||
def _request_params(self, query, offset, limit, resources):
|
||||
"""
|
||||
Construct a dict containing the required key-value pairs of parameters
|
||||
required in order to make the API request.
|
||||
|
||||
The documentation for the ``search`` resource can be found at
|
||||
https://comicvine.gamespot.com/api/documentation#toc-0-30.
|
||||
|
||||
Regarding 'limit', as per the documentation:
|
||||
|
||||
The number of results to display per page. This value defaults to
|
||||
10 and can not exceed this number.
|
||||
|
||||
:param query: The search query with which to make the request.
|
||||
:type query: str
|
||||
:param offset: The index of the first record returned.
|
||||
:type offset: int
|
||||
:param limit: How many records to return **(max 10)**
|
||||
:type limit: int
|
||||
:param resources: A list of resources to include in the search results.
|
||||
:type resources: list or None
|
||||
|
||||
:return: A dictionary of request parameters.
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
return {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
"limit": min(10, limit), # hard limit of 10
|
||||
"offset": max(0, offset), # cannot provide negative offset
|
||||
"query": query,
|
||||
"resources": self._validate_resources(resources),
|
||||
}
|
||||
|
||||
def _validate_resources(self, resources):
|
||||
"""
|
||||
Provided a list of resources, first convert it to a set and perform an
|
||||
intersection with the set of valid resource types, ``RESOURCE_TYPES``.
|
||||
Return a comma-separted string of the remaining valid resources, or
|
||||
None if the set is empty.
|
||||
|
||||
:param resources: A list of resources to include in the search results.
|
||||
:type resources: list or None
|
||||
|
||||
:return: A comma-separated string of valid resources.
|
||||
:rtype: str or None
|
||||
"""
|
||||
|
||||
if not resources:
|
||||
return None
|
||||
|
||||
valid_resources = self.RESOURCE_TYPES & set(resources)
|
||||
return ",".join(valid_resources) if valid_resources else None
|
||||
|
||||
def _query_api(self, params):
|
||||
"""
|
||||
Query the ComicVine API's ``search`` resource, providing the required
|
||||
headers and parameters with the request. Optionally allow the caller
|
||||
of the function to disable the request cache.
|
||||
|
||||
If an error occurs during the request, handle it accordingly. Upon
|
||||
success, return the JSON from the response.
|
||||
|
||||
:param params: Parameters to include with the request.
|
||||
:type params: dict
|
||||
:param use_cache: Toggle the use of requests_cache.
|
||||
:type use_cache: bool
|
||||
|
||||
:return: The JSON contained in the response.
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
# Since we're performing the identical action regardless of whether
|
||||
# or not the request cache is to be used, store the procedure in a
|
||||
# local function to avoid repetition.
|
||||
def __httpget():
|
||||
response = requests.get(
|
||||
self.API_URL, headers=self.HEADERS, params=params
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
return response.json()
|
||||
|
||||
return __httpget()
|
||||
|
||||
def _handle_http_error(self, response):
|
||||
"""
|
||||
Provided a ``requests.Response`` object, if the status code is
|
||||
anything other than **200**, we will treat it as an error.
|
||||
|
||||
Using the response's status code, determine which type of exception to
|
||||
raise. Construct an exception message from the response's status code
|
||||
and reason properties before raising the exception.
|
||||
|
||||
:param response: The requests.Response object returned by the HTTP
|
||||
request.
|
||||
:type response: requests.Response
|
||||
|
||||
:raises ComicVineUnauthorizedException: if no API key provided.
|
||||
:raises ComicVineForbiddenException: if no User-Agent header provided.
|
||||
:raises ComicVineApiException: if an unidentified error occurs.
|
||||
"""
|
||||
|
||||
exception = {
|
||||
401: Exception,
|
||||
403: Exception,
|
||||
}.get(response.status_code, Exception)
|
||||
message = f"{response.status_code} {response.reason}"
|
||||
|
||||
raise exception(message)
|
||||
|
||||
|
||||
def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warn("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
client = ComicVineClient(
|
||||
api_key=getattr(settings, "COMICVINE_API_KEY", None)
|
||||
)
|
||||
result = [
|
||||
r
|
||||
for r in client.search(title).get("results")
|
||||
if r.get("resource_type") == "volume"
|
||||
][0]
|
||||
|
||||
if "volume" not in result.keys():
|
||||
logger.warn("No result found on ComicVine", extra={"title": title})
|
||||
return {}
|
||||
|
||||
title = " ".join([result.get("volume").get("name"), result.get("name)")])
|
||||
data_dict = {
|
||||
"title": title,
|
||||
"cover_url": result.get("image").get("original_url"),
|
||||
"comicvine_data": {
|
||||
"id": result.get("id"),
|
||||
"site_detail_url": result.get("site_detail_url"),
|
||||
"description": result.get("description"),
|
||||
"image": result.get("image").get("original_url"),
|
||||
},
|
||||
}
|
||||
|
||||
return data_dict
|
||||
@ -1,22 +1,20 @@
|
||||
import codecs
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Iterable, List
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from books.models import Author, Book, Page
|
||||
from books.models import Author, Book
|
||||
from books.openlibrary import get_author_openlibrary_id
|
||||
from django.db.models import Sum
|
||||
from pylast import httpx, tempfile
|
||||
from scrobbles.models import Scrobble
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import get_user_model
|
||||
from stream_sqlite import stream_sqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class KoReaderBookColumn(Enum):
|
||||
@ -43,11 +41,79 @@ class KoReaderPageStatColumn(Enum):
|
||||
|
||||
|
||||
def _sqlite_bytes(sqlite_url):
|
||||
with httpx.stream("GET", sqlite_url) as r:
|
||||
yield from r.iter_bytes(chunk_size=65_536)
|
||||
with requests.get(sqlite_url, stream=True) as r:
|
||||
yield from r.iter_content(chunk_size=65_536)
|
||||
|
||||
|
||||
def get_book_map_from_sqlite(rows: Iterable) -> dict:
|
||||
# Grace period between page reads for it to be a new scrobble
|
||||
SESSION_GAP_SECONDS = 1800 # a half hour
|
||||
|
||||
|
||||
def get_author_str_from_row(row):
|
||||
"""Given a the raw author string from KoReader, convert it to a single line and
|
||||
strip the middle initials, as OpenLibrary lookup usually fails with those.
|
||||
"""
|
||||
ko_authors = row[KoReaderBookColumn.AUTHORS.value].replace("\n", ", ")
|
||||
# Strip middle initials, OpenLibrary often fails with these
|
||||
return re.sub(" [A-Z]. ", " ", ko_authors)
|
||||
|
||||
|
||||
def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
|
||||
"""Takes a string of authors from KoReader and returns a list
|
||||
of Authors from our database
|
||||
"""
|
||||
author_str_list = ko_author_str.split(", ")
|
||||
author_list = []
|
||||
for author_str in author_str_list:
|
||||
logger.debug(f"Looking up author {author_str}")
|
||||
# KoReader gave us nothing, bail
|
||||
if author_str == "N/A":
|
||||
logger.warn(f"KoReader author string is N/A, no authors to find")
|
||||
continue
|
||||
|
||||
author = Author.objects.filter(name=author_str).first()
|
||||
if not author:
|
||||
author = Author.objects.create(
|
||||
name=author_str,
|
||||
openlibrary_id=get_author_openlibrary_id(author_str),
|
||||
)
|
||||
author.fix_metadata()
|
||||
logger.debug(f"Created author {author}")
|
||||
author_list.append(author)
|
||||
return author_list
|
||||
|
||||
|
||||
def create_book_from_row(row: list):
|
||||
# No KoReader book yet, create it
|
||||
author_str = get_author_str_from_row(row)
|
||||
total_pages = row[KoReaderBookColumn.PAGES.value]
|
||||
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
|
||||
|
||||
book = Book.objects.create(
|
||||
title=row[KoReaderBookColumn.TITLE.value],
|
||||
pages=total_pages,
|
||||
koreader_data_by_hash={
|
||||
str(row[KoReaderBookColumn.MD5.value]): {
|
||||
"title": row[KoReaderBookColumn.TITLE.value],
|
||||
"author_str": author_str,
|
||||
"book_id": row[KoReaderBookColumn.ID.value],
|
||||
"pages": total_pages,
|
||||
}
|
||||
},
|
||||
run_time_seconds=run_time,
|
||||
)
|
||||
book.fix_metadata()
|
||||
|
||||
# Add authors
|
||||
author_list = lookup_or_create_authors_from_author_str(author_str)
|
||||
if author_list:
|
||||
book.authors.add(*author_list)
|
||||
|
||||
# self._lookup_authors
|
||||
return book
|
||||
|
||||
|
||||
def build_book_map(rows) -> dict:
|
||||
"""Given an interable of sqlite rows from the books table, lookup existing
|
||||
books, create ones that don't exist, and return a mapping of koreader IDs to
|
||||
primary key IDs for page creation.
|
||||
@ -56,204 +122,284 @@ def get_book_map_from_sqlite(rows: Iterable) -> dict:
|
||||
book_id_map = {}
|
||||
|
||||
for book_row in rows:
|
||||
if (
|
||||
book_row[KoReaderBookColumn.TITLE.value]
|
||||
== "KOReader Quickstart Guide"
|
||||
):
|
||||
logger.info(
|
||||
"Ignoring the KOReader quickstart guide. No on wants that."
|
||||
)
|
||||
continue
|
||||
book = Book.objects.filter(
|
||||
koreader_md5=book_row[KoReaderBookColumn.MD5.value]
|
||||
koreader_data_by_hash__icontains=book_row[
|
||||
KoReaderBookColumn.MD5.value
|
||||
]
|
||||
).first()
|
||||
|
||||
if not book:
|
||||
book, created = Book.objects.get_or_create(
|
||||
title=book_row[KoReaderBookColumn.TITLE.value]
|
||||
)
|
||||
|
||||
if created:
|
||||
total_pages = book_row[KoReaderBookColumn.PAGES.value]
|
||||
run_time = total_pages * book.AVG_PAGE_READING_SECONDS
|
||||
ko_authors = book_row[
|
||||
KoReaderBookColumn.AUTHORS.value
|
||||
].replace("\n", ", ")
|
||||
# Strip middle initials, OpenLibrary often fails with these
|
||||
ko_authors = re.sub(" [A-Z]. ", " ", ko_authors)
|
||||
book_dict = {
|
||||
"title": book_row[KoReaderBookColumn.TITLE.value],
|
||||
"pages": total_pages,
|
||||
"koreader_md5": book_row[KoReaderBookColumn.MD5.value],
|
||||
"koreader_id": int(book_row[KoReaderBookColumn.ID.value]),
|
||||
"koreader_authors": ko_authors,
|
||||
"run_time_seconds": run_time,
|
||||
}
|
||||
Book.objects.filter(pk=book.id).update(**book_dict)
|
||||
|
||||
# Add authors
|
||||
authors = ko_authors.split(", ")
|
||||
author_list = []
|
||||
for author_str in authors:
|
||||
logger.debug(f"Looking up author {author_str}")
|
||||
if author_str == "N/A":
|
||||
continue
|
||||
|
||||
author, created = Author.objects.get_or_create(
|
||||
name=author_str
|
||||
)
|
||||
if created:
|
||||
author.openlibrary_id = get_author_openlibrary_id(
|
||||
author_str
|
||||
)
|
||||
author.save(update_fields=["openlibrary_id"])
|
||||
author.fix_metadata()
|
||||
logger.debug(f"Created author {author}")
|
||||
book.authors.add(author)
|
||||
|
||||
# This will try to fix metadata by looking it up on OL
|
||||
book.fix_metadata()
|
||||
book = create_book_from_row(book_row)
|
||||
|
||||
book.refresh_from_db()
|
||||
total_seconds = 0
|
||||
if book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]:
|
||||
total_seconds = book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]
|
||||
|
||||
book_id_map[book_row[KoReaderBookColumn.ID.value]] = (
|
||||
book.id,
|
||||
total_seconds,
|
||||
)
|
||||
|
||||
book_id_map[book_row[KoReaderBookColumn.ID.value]] = {
|
||||
"book_id": book.id,
|
||||
"hash": book_row[KoReaderBookColumn.MD5.value],
|
||||
"total_seconds": total_seconds,
|
||||
}
|
||||
return book_id_map
|
||||
|
||||
|
||||
def build_scrobbles_from_pages(
|
||||
rows: Iterable, book_id_map: dict, user_id: int
|
||||
) -> List[Scrobble]:
|
||||
new_scrobbles = []
|
||||
def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
||||
"""Given rows of page data from KoReader, parse each row and build
|
||||
scrobbles for our user, loading the page data into the page_data
|
||||
field on the scrobble instance.
|
||||
"""
|
||||
book_ids_not_found = []
|
||||
for page_row in page_rows:
|
||||
koreader_book_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
|
||||
|
||||
if koreader_book_id not in book_map.keys():
|
||||
book_ids_not_found.append(koreader_book_id)
|
||||
continue
|
||||
|
||||
if "pages" not in book_map[koreader_book_id].keys():
|
||||
book_map[koreader_book_id]["pages"] = {}
|
||||
|
||||
new_scrobbles = []
|
||||
pages_found = []
|
||||
book_read_time_map = {}
|
||||
for page_row in rows:
|
||||
koreader_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
|
||||
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
||||
ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||
book_id = book_id_map[koreader_id][0]
|
||||
book_read_time_map[book_id] = book_id_map[koreader_id][1]
|
||||
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
||||
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||
|
||||
page, page_created = Page.objects.get_or_create(
|
||||
book_id=book_id, number=page_number, user_id=user_id
|
||||
book_map[koreader_book_id]["pages"][page_number] = {
|
||||
"duration": duration,
|
||||
"start_ts": start_ts,
|
||||
"end_ts": start_ts + duration,
|
||||
}
|
||||
if book_ids_not_found:
|
||||
logger.info(
|
||||
f"Found pages for books not in file: {set(book_ids_not_found)}"
|
||||
)
|
||||
if page_created:
|
||||
page.start_time = datetime.utcfromtimestamp(ts).replace(
|
||||
tzinfo=pytz.utc
|
||||
return book_map
|
||||
|
||||
|
||||
def build_scrobbles_from_book_map(
|
||||
book_map: dict, user: "User"
|
||||
) -> list["Scrobble"]:
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
|
||||
scrobbles_to_create = []
|
||||
|
||||
pages_not_found = []
|
||||
for koreader_book_id, book_dict in book_map.items():
|
||||
book_id = book_dict["book_id"]
|
||||
if "pages" not in book_dict.keys():
|
||||
pages_not_found.append(book_id)
|
||||
continue
|
||||
|
||||
should_create_scrobble = False
|
||||
scrobble_page_data = {}
|
||||
playback_position_seconds = 0
|
||||
prev_page_stats = {}
|
||||
last_page_number = 0
|
||||
|
||||
pages_processed = 0
|
||||
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
||||
ordered_pages = sorted(
|
||||
book_map[koreader_book_id]["pages"].items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
)
|
||||
|
||||
for cur_page_number, stats in ordered_pages:
|
||||
pages_processed += 1
|
||||
|
||||
seconds_from_last_page = 0
|
||||
if prev_page_stats:
|
||||
seconds_from_last_page = stats.get(
|
||||
"end_ts"
|
||||
) - prev_page_stats.get("start_ts")
|
||||
|
||||
playback_position_seconds = playback_position_seconds + stats.get(
|
||||
"duration"
|
||||
)
|
||||
page.duration_seconds = page_row[
|
||||
KoReaderPageStatColumn.DURATION.value
|
||||
]
|
||||
page.save(update_fields=["start_time", "duration_seconds"])
|
||||
pages_found.append(page)
|
||||
|
||||
playback_position_seconds = 0
|
||||
for page in set(pages_found):
|
||||
# Add up page seconds to set the aggregate time of all pages to reading time
|
||||
playback_position_seconds = (
|
||||
playback_position_seconds + page.duration_seconds
|
||||
)
|
||||
if page.is_scrobblable:
|
||||
# Check to see if a scrobble with this timestamp, book and user already exists
|
||||
scrobble = Scrobble.objects.filter(
|
||||
timestamp=page.start_time,
|
||||
book_id=page.book_id,
|
||||
user_id=user_id,
|
||||
).first()
|
||||
if not scrobble:
|
||||
logger.debug(
|
||||
f"Queueing scrobble for {page.book}, page {page.number}"
|
||||
end_of_reading = pages_processed == total_pages_read
|
||||
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
|
||||
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
||||
if (
|
||||
is_session_gap and not big_jump_to_this_page
|
||||
) or end_of_reading:
|
||||
should_create_scrobble = True
|
||||
|
||||
if should_create_scrobble:
|
||||
scrobble_page_data = dict(
|
||||
sorted(
|
||||
scrobble_page_data.items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
)
|
||||
)
|
||||
new_scrobble = Scrobble(
|
||||
book_id=page.book_id,
|
||||
user_id=user_id,
|
||||
source="KOReader",
|
||||
timestamp=page.start_time,
|
||||
played_to_completion=True,
|
||||
playback_position_seconds=playback_position_seconds,
|
||||
in_progress=False,
|
||||
book_pages_read=page.number,
|
||||
long_play_complete=False,
|
||||
)
|
||||
new_scrobbles.append(new_scrobble)
|
||||
# After setting a scrobblable page, reset our accumulator
|
||||
playback_position_seconds = 0
|
||||
return new_scrobbles
|
||||
try:
|
||||
first_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[0]
|
||||
)
|
||||
last_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
)
|
||||
except IndexError:
|
||||
logger.error(
|
||||
"Could not process book, no page data found",
|
||||
extra={"scrobble_page_data": scrobble_page_data},
|
||||
)
|
||||
continue
|
||||
|
||||
timezone = user.profile.timezone
|
||||
|
||||
timestamp = datetime.fromtimestamp(
|
||||
int(first_page.get("start_ts"))
|
||||
).replace(tzinfo=pytz.timezone(timezone))
|
||||
|
||||
# Add a shim here temporarily to fix imports while we were in France
|
||||
# if date is between 10/15 and 12/15, cast it to Europe/Central
|
||||
if (
|
||||
datetime(2023, 10, 15).replace(
|
||||
tzinfo=pytz.timezone("Europe/Paris")
|
||||
)
|
||||
<= timestamp
|
||||
<= datetime(2023, 12, 15).replace(
|
||||
tzinfo=pytz.timezone("Europe/Paris")
|
||||
)
|
||||
):
|
||||
timezone = "Europe/Paris"
|
||||
|
||||
stop_timestamp = datetime.fromtimestamp(
|
||||
int(last_page.get("end_ts"))
|
||||
).replace(tzinfo=pytz.timezone(timezone))
|
||||
|
||||
if (
|
||||
timestamp.tzinfo._dst.seconds == 0
|
||||
or stop_timestamp.tzinfo._dst.seconds == 0
|
||||
):
|
||||
timestamp = timestamp - timedelta(hours=1)
|
||||
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||
|
||||
scrobble = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
book_id=book_id,
|
||||
user_id=user.id,
|
||||
).first()
|
||||
|
||||
if not scrobble:
|
||||
logger.info(
|
||||
f"Queueing scrobble for {book_id}, page {cur_page_number}"
|
||||
)
|
||||
log_data = {
|
||||
"koreader_hash": book_dict.get("hash"),
|
||||
"page_data": scrobble_page_data,
|
||||
"pages_read": cur_page_number,
|
||||
}
|
||||
scrobbles_to_create.append(
|
||||
Scrobble(
|
||||
book_id=book_id,
|
||||
user_id=user.id,
|
||||
source="KOReader",
|
||||
media_type=Scrobble.MediaType.BOOK,
|
||||
timestamp=timestamp,
|
||||
log=log_data,
|
||||
stop_timestamp=stop_timestamp,
|
||||
playback_position_seconds=playback_position_seconds,
|
||||
in_progress=False,
|
||||
played_to_completion=True,
|
||||
long_play_complete=False,
|
||||
timezone=timezone,
|
||||
)
|
||||
)
|
||||
# Then start over
|
||||
should_create_scrobble = False
|
||||
playback_position_seconds = 0
|
||||
scrobble_page_data = {}
|
||||
|
||||
# We accumulate pages for the scrobble until we should create a new one
|
||||
scrobble_page_data[cur_page_number] = stats
|
||||
|
||||
last_page_number = cur_page_number
|
||||
prev_page_stats = stats
|
||||
if pages_not_found:
|
||||
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
||||
return scrobbles_to_create
|
||||
|
||||
|
||||
def enrich_koreader_scrobbles(scrobbles: list) -> None:
|
||||
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
|
||||
"""Given a list of scrobbles, update pages read, long play seconds and check
|
||||
for media completion"""
|
||||
|
||||
for scrobble in scrobbles:
|
||||
scrobble.book_pages_read = scrobble.book.page_set.last().number
|
||||
# But if there's a next scrobble, set pages read to their starting page
|
||||
#
|
||||
if scrobble.next:
|
||||
scrobble.book_pages_read = scrobble.next.book_pages_read - 1
|
||||
scrobble.long_play_seconds = scrobble.book.page_set.filter(
|
||||
number__lte=scrobble.book_pages_read
|
||||
).aggregate(Sum("duration_seconds"))["duration_seconds__sum"]
|
||||
|
||||
scrobble.save(update_fields=["book_pages_read", "long_play_seconds"])
|
||||
|
||||
|
||||
def process_koreader_sqlite_url(file_url, user_id) -> list:
|
||||
book_id_map = {}
|
||||
new_scrobbles = []
|
||||
|
||||
for table_name, pragma_table_info, rows in stream_sqlite(
|
||||
_sqlite_bytes(file_url), max_buffer_size=1_048_576
|
||||
):
|
||||
logger.debug(f"Found table {table_name} - processing")
|
||||
if table_name == "book":
|
||||
book_id_map = get_book_map_from_sqlite(rows)
|
||||
|
||||
if table_name == "page_stat_data":
|
||||
new_scrobbles = build_scrobbles_from_pages(
|
||||
rows, book_id_map, user_id
|
||||
if scrobble.previous and not scrobble.previous.long_play_complete:
|
||||
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
|
||||
scrobble.previous.long_play_seconds or 0
|
||||
)
|
||||
logger.debug(f"Creating {len(new_scrobbles)} new scrobbles")
|
||||
else:
|
||||
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
||||
scrobble.log["book_pages_read"] = scrobble.calc_pages_read()
|
||||
|
||||
created = []
|
||||
if new_scrobbles:
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
enrich_koreader_scrobbles(created)
|
||||
logger.info(
|
||||
f"Created {len(created)} scrobbles",
|
||||
extra={"created_scrobbles": created},
|
||||
)
|
||||
return created
|
||||
scrobble.save(update_fields=["log", "long_play_seconds"])
|
||||
|
||||
|
||||
def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
"""Given a sqlite file from KoReader, open the book table, iterate
|
||||
over rows creating scrobbles from each book found"""
|
||||
# Create a SQL connection to our SQLite database
|
||||
con = sqlite3.connect(file_path)
|
||||
cur = con.cursor()
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
|
||||
book_id_map = get_book_map_from_sqlite(cur.execute("SELECT * FROM book"))
|
||||
new_scrobbles = build_scrobbles_from_pages(
|
||||
cur.execute("SELECT * from page_stat_data"), book_id_map, user_id
|
||||
)
|
||||
new_scrobbles = []
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
tz = pytz.utc
|
||||
if user:
|
||||
tz = user.profile.timezone
|
||||
|
||||
is_os_file = "https://" not in file_path
|
||||
if is_os_file:
|
||||
# Loading sqlite file from local filesystem
|
||||
con = sqlite3.connect(file_path)
|
||||
cur = con.cursor()
|
||||
try:
|
||||
book_map = build_book_map(cur.execute("SELECT * FROM book"))
|
||||
except sqlite3.OperationalError:
|
||||
logger.warning("KOReader sqlite file had not table: book")
|
||||
return new_scrobbles
|
||||
|
||||
book_map = build_page_data(
|
||||
cur.execute(
|
||||
"SELECT * from page_stat_data ORDER BY id_book, start_time"
|
||||
),
|
||||
book_map,
|
||||
tz,
|
||||
)
|
||||
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||
else:
|
||||
# Streaming the sqlite file off S3
|
||||
book_map = {}
|
||||
for table_name, pragma_table_info, rows in stream_sqlite(
|
||||
_sqlite_bytes(file_path), max_buffer_size=1_048_576
|
||||
):
|
||||
logger.debug(f"Found table {table_name} - processing")
|
||||
if table_name == "book":
|
||||
book_map = build_book_map(rows)
|
||||
|
||||
for table_name, pragma_table_info, rows in stream_sqlite(
|
||||
_sqlite_bytes(file_path), max_buffer_size=1_048_576
|
||||
):
|
||||
if table_name == "page_stat_data":
|
||||
book_map = build_page_data(rows, book_map, tz)
|
||||
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||
|
||||
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")
|
||||
created = []
|
||||
if new_scrobbles:
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
enrich_koreader_scrobbles(created)
|
||||
fix_long_play_stats_for_scrobbles(created)
|
||||
logger.info(
|
||||
f"Created {len(created)} scrobbles",
|
||||
extra={"created_scrobbles": created},
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
def process_koreader_sqlite(file_path: str, user_id: int) -> list:
|
||||
is_os_file = "https://" not in file_path
|
||||
|
||||
if is_os_file:
|
||||
created = process_koreader_sqlite_file(file_path, user_id)
|
||||
else:
|
||||
created = process_koreader_sqlite_url(file_path, user_id)
|
||||
return created
|
||||
|
||||
124
vrobbler/apps/books/locg.py
Normal file
124
vrobbler/apps/books/locg.py
Normal file
@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HEADERS = {
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
"accept-language": "en-GB,en;q=0.9",
|
||||
}
|
||||
LOCG_WRTIER_URL = ""
|
||||
LOCG_WRITER_DETAIL_URL = "https://leagueofcomicgeeks.com/people/{slug}"
|
||||
LOCG_SEARCH_URL = (
|
||||
"https://leagueofcomicgeeks.com/search/ajax_issues?query={query}"
|
||||
)
|
||||
LOCG_DETAIL_URL = "https://leagueofcomicgeeks.com/comic/{locg_slug}"
|
||||
|
||||
|
||||
def strip_and_clean(text):
|
||||
return text.strip("\n").strip()
|
||||
|
||||
|
||||
def get_rating_from_soup(soup) -> Optional[int]:
|
||||
rating = None
|
||||
try:
|
||||
potential_rating = soup.find("div", class_="allmusic-rating")
|
||||
if potential_rating:
|
||||
rating = int(strip_and_clean(potential_rating.get_text()))
|
||||
except ValueError:
|
||||
pass
|
||||
return rating
|
||||
|
||||
|
||||
def lookup_comic_writer_by_locg_slug(slug: str) -> dict:
|
||||
data_dict = {}
|
||||
writer_url = LOCG_WRITER_DETAIL_URL.format(slug=slug)
|
||||
|
||||
response = requests.get(writer_url, headers=HEADERS)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.info(f"Bad http response from LOCG {response}")
|
||||
return data_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
data_dict["locg_slug"] = slug
|
||||
data_dict["name"] = soup.find("h1").text.strip()
|
||||
data_dict["photo_url"] = soup.find("div", class_="avatar").img.get("src")
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
def lookup_comic_by_locg_slug(slug: str) -> dict:
|
||||
data_dict = {}
|
||||
product_url = LOCG_DETAIL_URL.format(locg_slug=slug)
|
||||
|
||||
response = requests.get(product_url, headers=HEADERS)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.info(f"Bad http response from LOCG {response}")
|
||||
return data_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
try:
|
||||
data_dict["title"] = soup.find("h1").text.strip()
|
||||
data_dict["summary"] = soup.find("p").text.strip()
|
||||
data_dict["cover_url"] = (
|
||||
soup.find("div", class_="cover-art").find("img").get("src")
|
||||
)
|
||||
attrs = soup.findAll("div", class_="details-addtl-block")
|
||||
try:
|
||||
data_dict["pages"] = (
|
||||
attrs[1]
|
||||
.find("div", class_="value")
|
||||
.text.split("pages")[0]
|
||||
.strip()
|
||||
)
|
||||
except IndexError:
|
||||
logger.warn(f"No ISBN field")
|
||||
try:
|
||||
data_dict["isbn"] = (
|
||||
attrs[3].find("div", class_="value").text.strip()
|
||||
)
|
||||
except IndexError:
|
||||
logger.warn(f"No ISBN field")
|
||||
|
||||
writer_slug = None
|
||||
try:
|
||||
writer_slug = (
|
||||
soup.findAll("div", class_="name")[5]
|
||||
.a.get("href")
|
||||
.split("people/")[1]
|
||||
)
|
||||
except IndexError:
|
||||
logger.warn(f"No wrtier found")
|
||||
if writer_slug:
|
||||
data_dict["locg_writer_slug"] = writer_slug
|
||||
|
||||
except AttributeError:
|
||||
logger.warn(f"Trouble parsing HTML, elements missing")
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
def lookup_comic_from_locg(title: str) -> dict:
|
||||
search_url = LOCG_SEARCH_URL.format(query=title)
|
||||
response = requests.get(search_url, headers=HEADERS)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warn(f"Bad http response from LOCG {response}")
|
||||
return {}
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
try:
|
||||
slug = soup.findAll("a")[1].get("href").split("comic/")[1]
|
||||
except IndexError:
|
||||
logger.warn(f"No comic found on LOCG for {title}")
|
||||
return {}
|
||||
|
||||
return lookup_comic_by_locg_slug(slug)
|
||||
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
from books.models import Book
|
||||
from django.core.management.base import BaseCommand
|
||||
from scrobbles.models import Scrobble
|
||||
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
|
||||
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Grace period between page reads for it to be a new scrobble
|
||||
SESSION_GAP_SECONDS = 1800 # a half hour
|
||||
|
||||
def update_scrobble_from_page_data(scrobble, commit=True):
|
||||
page_list = list(scrobble.book_page_data.items())
|
||||
first_page_start_ts = datetime.fromtimestamp(page_list[0][1]["start_ts"])
|
||||
last_page_end_ts = datetime.fromtimestamp(page_list[-1][1]["end_ts"])
|
||||
|
||||
if (
|
||||
datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15)
|
||||
):
|
||||
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||
else:
|
||||
first_page_start_ts.replace(tzinfo=pytz.timezone("US/Eastern"))
|
||||
last_page_end_ts.replace(tzinfo=pytz.timezone("US/Eastern"))
|
||||
|
||||
scrobble.timestamp = first_page_start_ts
|
||||
scrobble.stop_timestamp = last_page_end_ts
|
||||
if commit:
|
||||
scrobble.save(update_fields=["timestamp", "stop_timestamp"])
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
scrobbles_to_create = []
|
||||
|
||||
for scrobble in Scrobble.objects.filter(media_type="Book", source="KOReader"):
|
||||
update_scrobble_from_page_data(scrobble)
|
||||
@ -0,0 +1,160 @@
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
from books.models import Book
|
||||
from django.core.management.base import BaseCommand
|
||||
from scrobbles.models import Scrobble
|
||||
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
|
||||
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Grace period between page reads for it to be a new scrobble
|
||||
SESSION_GAP_SECONDS = 1800 # a half hour
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
scrobbles_to_create = []
|
||||
|
||||
for book in Book.objects.filter(koreader_id__isnull=False):
|
||||
book.scrobble_set.all().delete()
|
||||
|
||||
koreader_data = book.koreader_data_by_hash or {}
|
||||
if book.koreader_md5:
|
||||
koreader_data[book.koreader_md5] = {
|
||||
"title": book.title,
|
||||
"book_id": book.koreader_id,
|
||||
"author_str": book.koreader_authors,
|
||||
"pages": book.pages,
|
||||
}
|
||||
book.koreader_data_by_hash = koreader_data
|
||||
book.save(update_fields=["koreader_data_by_hash"])
|
||||
|
||||
# Next parse all this book's pages into new scrobbles
|
||||
should_create_scrobble = False
|
||||
scrobble_page_data = {}
|
||||
playback_position_seconds = 0
|
||||
prev_page = None
|
||||
|
||||
pages_processed = 0
|
||||
total_pages = book.pages
|
||||
for page in book.page_set.order_by("number"):
|
||||
user = page.user
|
||||
book_id = page.book.id
|
||||
pages_processed += 1
|
||||
scrobble_page_data[page.number] = {
|
||||
"duration": page.duration_seconds,
|
||||
"start_ts": page.start_time.timestamp(),
|
||||
"end_ts": page.end_time.timestamp(),
|
||||
}
|
||||
seconds_from_last_page = 0
|
||||
if prev_page:
|
||||
seconds_from_last_page = (
|
||||
page.end_time.timestamp()
|
||||
- prev_page.start_time.timestamp()
|
||||
)
|
||||
playback_position_seconds = (
|
||||
playback_position_seconds + page.duration_seconds
|
||||
)
|
||||
|
||||
end_of_reading = pages_processed == total_pages
|
||||
big_jump_to_this_page = False
|
||||
if prev_page:
|
||||
big_jump_to_this_page = (
|
||||
page.number - prev_page.number
|
||||
) > 10
|
||||
if (
|
||||
seconds_from_last_page > SESSION_GAP_SECONDS
|
||||
and not big_jump_to_this_page
|
||||
):
|
||||
should_create_scrobble = True
|
||||
|
||||
if should_create_scrobble:
|
||||
first_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[0]
|
||||
)
|
||||
last_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
)
|
||||
start_ts = int(first_page.get("start_ts"))
|
||||
end_ts = start_ts + playback_position_seconds
|
||||
|
||||
timestamp = datetime.fromtimestamp(start_ts).replace(
|
||||
tzinfo=user.profile.tzinfo
|
||||
)
|
||||
stop_timestamp = datetime.fromtimestamp(end_ts).replace(
|
||||
tzinfo=user.profile.tzinfo
|
||||
)
|
||||
# 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")
|
||||
)
|
||||
):
|
||||
timestamp.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||
|
||||
elif (
|
||||
timestamp.tzinfo._dst.seconds == 0
|
||||
or stop_timestamp.tzinfo._dst.seconds == 0
|
||||
):
|
||||
timestamp = timestamp - timedelta(hours=1)
|
||||
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||
|
||||
scrobble = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
book_id=book_id,
|
||||
user_id=user.id,
|
||||
).first()
|
||||
if scrobble:
|
||||
logger.info(
|
||||
f"Found existing scrobble {scrobble}, updating"
|
||||
)
|
||||
scrobble.book_page_data = scrobble_page_data
|
||||
scrobble.playback_position_seconds = (
|
||||
scrobble.calc_reading_duration()
|
||||
)
|
||||
scrobble.save(
|
||||
update_fields=[
|
||||
"book_page_data",
|
||||
"playback_position_seconds",
|
||||
]
|
||||
)
|
||||
if not scrobble:
|
||||
logger.info(
|
||||
f"Queueing scrobble for {book_id}, page {page.number}"
|
||||
)
|
||||
scrobbles_to_create.append(
|
||||
Scrobble(
|
||||
book_id=book_id,
|
||||
user_id=user.id,
|
||||
source="KOReader",
|
||||
media_type=Scrobble.MediaType.BOOK,
|
||||
timestamp=timestamp,
|
||||
stop_timestamp=stop_timestamp,
|
||||
playback_position_seconds=playback_position_seconds,
|
||||
book_koreader_hash=list(
|
||||
book.koreader_data_by_hash.keys()
|
||||
)[0],
|
||||
book_page_data=scrobble_page_data,
|
||||
book_pages_read=page.number,
|
||||
in_progress=False,
|
||||
played_to_completion=True,
|
||||
long_play_complete=False,
|
||||
)
|
||||
)
|
||||
# Then start over
|
||||
should_create_scrobble = False
|
||||
playback_position_seconds = 0
|
||||
scrobble_page_data = {}
|
||||
|
||||
prev_page = page
|
||||
|
||||
created = Scrobble.objects.bulk_create(scrobbles_to_create)
|
||||
fix_long_play_stats_for_scrobbles(created)
|
||||
@ -0,0 +1,29 @@
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
from books.models import Book
|
||||
from django.core.management.base import BaseCommand
|
||||
from scrobbles.models import Scrobble
|
||||
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
|
||||
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
total_books = Book.objects.all().count()
|
||||
processed_books = 0
|
||||
|
||||
for book in Book.objects.all():
|
||||
for scrobble in book.scrobble_set.all():
|
||||
log_data = {
|
||||
"koreader_hash": scrobble.book_koreader_hash,
|
||||
"page_data": scrobble.book_page_data,
|
||||
"pages_read": scrobble.book_pages_read,
|
||||
}
|
||||
scrobble.log = log_data
|
||||
scrobble.save(update_fields=["log"])
|
||||
processed_books += 1
|
||||
logger.info(f"Processed book {processed_books} of {total_books}")
|
||||
26
vrobbler/apps/books/migrations/0014_alter_book_genre.py
Normal file
26
vrobbler/apps/books/migrations/0014_alter_book_genre.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-18 02:33
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0040_alter_scrobble_media_type"),
|
||||
("books", "0013_page_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,30 @@
|
||||
# Generated by Django 4.1.7 on 2023-08-19 02:47
|
||||
|
||||
from django.db import migrations, models
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0043_scrobbledpage"),
|
||||
("books", "0014_alter_book_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="first_sentence",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.7 on 2023-08-26 04:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0015_alter_book_first_sentence_alter_book_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="locg_slug",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="summary",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
20
vrobbler/apps/books/migrations/0017_alter_book_authors.py
Normal file
20
vrobbler/apps/books/migrations/0017_alter_book_authors.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.1.7 on 2023-08-26 16:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0016_book_locg_slug_book_summary"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="authors",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, null=True, to="books.author"
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/books/migrations/0018_author_locg_slug.py
Normal file
18
vrobbler/apps/books/migrations/0018_author_locg_slug.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-08-31 03:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0017_alter_book_authors"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="locg_slug",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/books/migrations/0019_alter_book_authors.py
Normal file
18
vrobbler/apps/books/migrations/0019_alter_book_authors.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-11-21 23:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0018_author_locg_slug"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="authors",
|
||||
field=models.ManyToManyField(blank=True, to="books.author"),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.2.9 on 2024-01-29 05:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0019_alter_book_authors"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="comicvine_data",
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="comicvine_data",
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="koreader_data_by_hash",
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,5 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from datetime import timedelta, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
@ -13,8 +14,29 @@ from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableMixin
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.mixins import (
|
||||
LongPlayScrobblableMixin,
|
||||
ObjectWithGenres,
|
||||
ScrobblableMixin,
|
||||
)
|
||||
from scrobbles.utils import get_scrobbles_for_media
|
||||
from taggit.managers import TaggableManager
|
||||
from thefuzz import fuzz
|
||||
from vrobbler.apps.books.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
|
||||
from vrobbler.apps.books.locg import (
|
||||
lookup_comic_by_locg_slug,
|
||||
lookup_comic_from_locg,
|
||||
lookup_comic_writer_by_locg_slug,
|
||||
)
|
||||
from vrobbler.apps.scrobbles.dataclasses import BookLogData
|
||||
|
||||
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
@ -26,12 +48,26 @@ class Author(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||
headshot = models.ImageField(upload_to="books/authors/", **BNULL)
|
||||
headshot_small = ImageSpecField(
|
||||
source="headshot",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
headshot_medium = ImageSpecField(
|
||||
source="headshot",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
bio = models.TextField(**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)
|
||||
goodreads_id = models.CharField(max_length=255, **BNULL)
|
||||
librarything_id = models.CharField(max_length=255, **BNULL)
|
||||
comicvine_data = models.JSONField(**BNULL)
|
||||
amazon_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
@ -63,31 +99,54 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
authors = models.ManyToManyField(Author)
|
||||
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)
|
||||
isbn = models.CharField(max_length=255, **BNULL)
|
||||
pages = models.IntegerField(**BNULL)
|
||||
language = models.CharField(max_length=4, **BNULL)
|
||||
first_publish_year = models.IntegerField(**BNULL)
|
||||
first_sentence = models.CharField(max_length=255, **BNULL)
|
||||
first_sentence = models.TextField(**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_small = ImageSpecField(
|
||||
source="cover",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
cover_medium = ImageSpecField(
|
||||
source="cover",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
summary = models.TextField(**BNULL)
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.author}"
|
||||
return f"{self.title}"
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return f" by {self.author}"
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BookLogData
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.cover:
|
||||
url = self.cover.url
|
||||
url = self.cover_medium.url
|
||||
return url
|
||||
|
||||
def get_start_url(self):
|
||||
@ -96,32 +155,89 @@ class Book(LongPlayScrobblableMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def fix_metadata(self, force_update=False):
|
||||
if not self.openlibrary_id or force_update:
|
||||
def fix_metadata(self, data: dict = {}, force_update=False):
|
||||
if (not self.openlibrary_id or not self.locg_slug) or force_update:
|
||||
author_name = ""
|
||||
if self.author:
|
||||
author_name = self.author.name
|
||||
book_dict = lookup_book_from_openlibrary(self.title, author_name)
|
||||
if not book_dict:
|
||||
logger.warn(f"Book not found in OL {self.title}")
|
||||
|
||||
if not data:
|
||||
logger.warn(f"Checking openlibrary for {self.title}")
|
||||
if self.openlibrary_id and force_update:
|
||||
data = lookup_book_from_openlibrary(
|
||||
str(self.openlibrary_id)
|
||||
)
|
||||
else:
|
||||
data = lookup_book_from_openlibrary(
|
||||
str(self.title), author_name
|
||||
)
|
||||
|
||||
if not data:
|
||||
if self.locg_slug:
|
||||
logger.warn(
|
||||
f"Checking LOCG for {self.title} with slug {self.locg_slug}"
|
||||
)
|
||||
data = lookup_comic_by_locg_slug(str(self.locg_slug))
|
||||
else:
|
||||
logger.warn(f"Checking LOCG for {self.title}")
|
||||
data = lookup_comic_from_locg(str(self.title))
|
||||
|
||||
if not data and COMICVINE_API_KEY:
|
||||
logger.warn(f"Checking ComicVine for {self.title}")
|
||||
cv_client = ComicVineClient(api_key=COMICVINE_API_KEY)
|
||||
data = lookup_comic_from_comicvine(str(self.title))
|
||||
|
||||
if not data:
|
||||
logger.warn(f"Book not found in any sources: {self.title}")
|
||||
return
|
||||
|
||||
cover_url = book_dict.pop("cover_url", "")
|
||||
ol_author_id = book_dict.pop("ol_author_id", "")
|
||||
ol_author_name = book_dict.pop("ol_author_name", "")
|
||||
if book_dict.get("pages") == None:
|
||||
book_dict.pop("pages")
|
||||
# We can discard the author name from OL for now, we'll lookup details below
|
||||
data.pop("ol_author_name", "")
|
||||
if data.get("ol_author_id"):
|
||||
self.fix_authors_metadata(data.pop("ol_author_id", ""))
|
||||
if data.get("locg_writer_slug"):
|
||||
self.get_author_from_locg(data.pop("locg_writer_slug", ""))
|
||||
|
||||
ol_title = book_dict.get("title", "")
|
||||
ol_title = data.get("title", "")
|
||||
data.pop("ol_author_id", "")
|
||||
|
||||
if ol_title.lower() != self.title.lower():
|
||||
# Kick out a little warning if we're about to change KoReader's title
|
||||
if (
|
||||
fuzz.ratio(ol_title.lower(), str(self.title).lower()) < 80
|
||||
and not force_update
|
||||
):
|
||||
logger.warn(
|
||||
f"OL and KoReader disagree on this book title {self.title} != {ol_title}"
|
||||
f"OL and KoReader disagree on this book title {self.title} != {ol_title}, check manually"
|
||||
)
|
||||
self.openlibrary_id = data.get("openlibrary_id")
|
||||
self.save(update_fields=["openlibrary_id"])
|
||||
return
|
||||
|
||||
Book.objects.filter(pk=self.id).update(**book_dict)
|
||||
# If we don't know pages, don't overwrite existing with None
|
||||
if "pages" in data.keys() and data.get("pages") == None:
|
||||
data.pop("pages")
|
||||
|
||||
if (
|
||||
not isinstance(data.get("pages"), int)
|
||||
and "pages" in data.keys()
|
||||
):
|
||||
logger.info(
|
||||
f"Pages for {self} from OL expected to be int, but got {data.get('pages')}"
|
||||
)
|
||||
data.pop("pages")
|
||||
|
||||
# Pop this, so we can look it up later
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
subject_key_list = data.pop("subject_key_list", "")
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
Book.objects.filter(pk=self.id).update(**data)
|
||||
self.refresh_from_db()
|
||||
|
||||
if subject_key_list:
|
||||
self.genre.add(*subject_key_list)
|
||||
|
||||
if cover_url:
|
||||
r = requests.get(cover_url)
|
||||
if r.status_code == 200:
|
||||
@ -129,12 +245,65 @@ class Book(LongPlayScrobblableMixin):
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if self.pages:
|
||||
self.run_time_seconds = self.pages * int(
|
||||
self.run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def fix_authors_metadata(self, openlibrary_author_id):
|
||||
author = Author.objects.filter(
|
||||
openlibrary_id=openlibrary_author_id
|
||||
).first()
|
||||
if not author:
|
||||
data = lookup_author_from_openlibrary(openlibrary_author_id)
|
||||
author_image_url = data.pop("author_headshot_url", None)
|
||||
|
||||
author = Author.objects.create(**data)
|
||||
|
||||
if author_image_url:
|
||||
r = requests.get(author_image_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{author.name}_{author.uuid}.jpg"
|
||||
author.headshot.save(
|
||||
fname, ContentFile(r.content), save=True
|
||||
)
|
||||
self.authors.add(author)
|
||||
|
||||
def get_author_from_locg(self, locg_slug):
|
||||
writer = lookup_comic_writer_by_locg_slug(locg_slug)
|
||||
|
||||
author, created = Author.objects.get_or_create(
|
||||
name=writer["name"], locg_slug=writer["locg_slug"]
|
||||
)
|
||||
if (created or not author.headshot) and writer["photo_url"]:
|
||||
r = requests.get(writer["photo_url"])
|
||||
if r.status_code == 200:
|
||||
fname = f"{author.name}_{author.uuid}.jpg"
|
||||
author.headshot.save(fname, ContentFile(r.content), save=True)
|
||||
self.authors.add(author)
|
||||
|
||||
def page_data_for_user(
|
||||
self, user_id: int, convert_timestamps: bool = True
|
||||
) -> dict:
|
||||
scrobbles = self.scrobble_set.filter(user=user_id)
|
||||
|
||||
pages = {}
|
||||
for scrobble in scrobbles:
|
||||
if scrobble.logdata.page_data:
|
||||
for page, data in scrobble.logdata.page_data.items():
|
||||
if convert_timestamps:
|
||||
data["start_ts"] = datetime.fromtimestamp(
|
||||
data["start_ts"]
|
||||
)
|
||||
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
|
||||
pages[page] = data
|
||||
sorted_pages = OrderedDict(
|
||||
sorted(pages.items(), key=lambda x: x[1]["start_ts"])
|
||||
)
|
||||
|
||||
return sorted_pages
|
||||
|
||||
@property
|
||||
def author(self):
|
||||
return self.authors.first()
|
||||
@ -157,22 +326,35 @@ class Book(LongPlayScrobblableMixin):
|
||||
last_scrobble = get_scrobbles_for_media(self, user).last()
|
||||
progress = 0
|
||||
if last_scrobble:
|
||||
progress = int((last_scrobble.book_pages_read / self.pages) * 100)
|
||||
progress = int((last_scrobble.last_page_read / self.pages) * 100)
|
||||
return progress
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, data_dict: dict) -> "Game":
|
||||
from books.utils import update_or_create_book
|
||||
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
|
||||
book = cls.objects.filter(openlibrary_id=lookup_id).first()
|
||||
|
||||
return update_or_create_book(
|
||||
data_dict.get("title"), data_dict.get("author")
|
||||
)
|
||||
if not book:
|
||||
data = lookup_book_from_openlibrary(lookup_id, author)
|
||||
|
||||
if not data:
|
||||
logger.error(
|
||||
f"No book found on openlibrary, or in our database for {lookup_id}"
|
||||
)
|
||||
return book
|
||||
|
||||
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
|
||||
if book_created:
|
||||
book.fix_metadata(data=data)
|
||||
|
||||
return book
|
||||
|
||||
|
||||
class Page(TimeStampedModel):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
"""DEPRECATED, we need to migrate pages into page_data on scrobbles and move on"""
|
||||
|
||||
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)
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
import urllib
|
||||
|
||||
import requests
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
|
||||
@ -35,8 +38,11 @@ def get_author_openlibrary_id(name: str) -> str:
|
||||
logger.warn(f"No author results found from search for {name}")
|
||||
return ""
|
||||
|
||||
result = results.get("docs", [])
|
||||
return result[0].get("key")
|
||||
try:
|
||||
result = results.get("docs", [])[0]
|
||||
except IndexError:
|
||||
result = {"key": ""}
|
||||
return result.get("key")
|
||||
|
||||
|
||||
def lookup_author_from_openlibrary(olid: str) -> dict:
|
||||
@ -74,7 +80,9 @@ def lookup_author_from_openlibrary(olid: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
|
||||
def lookup_book_from_openlibrary(
|
||||
title: str, author: Optional[str] = None
|
||||
) -> dict:
|
||||
title_quoted = urllib.parse.quote(title)
|
||||
author_quoted = ""
|
||||
if author:
|
||||
@ -96,11 +104,22 @@ def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
|
||||
|
||||
top = None
|
||||
for result in results.get("docs"):
|
||||
# These Summary things suck and ruin our one-shot search
|
||||
if "Summary of" not in result.get("title"):
|
||||
if fuzz.ratio(title.lower(), result.get("title", "").lower()) > 90:
|
||||
top = result
|
||||
break
|
||||
|
||||
if not top:
|
||||
for result in results.get("docs"):
|
||||
# These Summary things suck and ruin our one-shot search
|
||||
if "Summary of" in result.get("title"):
|
||||
continue
|
||||
|
||||
if title.lower() in result.get("title", "").lower():
|
||||
top = result
|
||||
|
||||
if not top and len(results.get("docs")) > 0:
|
||||
top = results.get("docs")[0]
|
||||
|
||||
if not top:
|
||||
logger.warn(f"No book found for query {query}")
|
||||
return {}
|
||||
@ -126,4 +145,5 @@ def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
|
||||
"pages": top.get("number_of_pages_median", None),
|
||||
"cover_url": COVER_URL.format(id=ol_id),
|
||||
"ol_author_id": ol_author_id,
|
||||
"subject_key_list": top.get("subject_key", []),
|
||||
}
|
||||
|
||||
123
vrobbler/apps/books/tests/conftest.py
Normal file
123
vrobbler/apps/books/tests/conftest.py
Normal file
@ -0,0 +1,123 @@
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from vrobbler.apps.books.koreader import KoReaderBookColumn
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
ordinal = lambda n: "%d%s" % (
|
||||
n,
|
||||
"tsnrhtdd"[(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4],
|
||||
)
|
||||
AVERAGE_PAGE_READING_SECONDS = 60
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
status_code = 200
|
||||
|
||||
def status_code(self):
|
||||
return self.status_code
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_response():
|
||||
return DummyResponse()
|
||||
|
||||
|
||||
class KoReaderBookRows:
|
||||
id = 1
|
||||
DEFAULT_STR = "N/A"
|
||||
DEFAULT_INT = 0
|
||||
DEFAULT_TIME = 1703800469
|
||||
BOOK_ROWS = []
|
||||
PAGE_STATS_ROWS = []
|
||||
|
||||
def _gen_random_row(self, i):
|
||||
wiggle = random.randrange(15)
|
||||
title = f"Memoirs, Volume {i}"
|
||||
return [
|
||||
i,
|
||||
title,
|
||||
f"Lord Beaverbrook the {ordinal(i)}",
|
||||
self.DEFAULT_INT + wiggle / 10,
|
||||
self.DEFAULT_TIME + i * wiggle,
|
||||
0,
|
||||
300 + wiggle,
|
||||
self.DEFAULT_STR,
|
||||
self.DEFAULT_STR,
|
||||
hashlib.md5(title.encode()).hexdigest(),
|
||||
i * wiggle * 20,
|
||||
120,
|
||||
]
|
||||
|
||||
def _generate_random_book_rows(self, book_count):
|
||||
if book_count > 0:
|
||||
for i in range(1, book_count + 1):
|
||||
self.BOOK_ROWS.append(self._gen_random_row(i))
|
||||
|
||||
def _generate_custom_book_row(self, **kwargs):
|
||||
title = kwargs.get("title", self.DEFAULT_STR)
|
||||
if title and title != "N/A":
|
||||
self.BOOK_ROWS.append(
|
||||
[
|
||||
kwargs.get("id", self.id),
|
||||
kwargs.get("title", self.DEFAULT_STR),
|
||||
kwargs.get("authors", self.DEFAULT_STR),
|
||||
kwargs.get("notes", self.DEFAULT_INT),
|
||||
kwargs.get("last_open", self.DEFAULT_TIME),
|
||||
kwargs.get("highlights", self.DEFAULT_INT),
|
||||
kwargs.get("pages", self.DEFAULT_INT),
|
||||
kwargs.get("series", self.DEFAULT_STR),
|
||||
kwargs.get("language", self.DEFAULT_STR),
|
||||
hashlib.md5(title.encode()),
|
||||
kwargs.get("total_read_time", self.DEFAULT_INT),
|
||||
kwargs.get("total_read_pages", self.DEFAULT_INT),
|
||||
]
|
||||
)
|
||||
|
||||
def _generate_random_page_stats_rows(self):
|
||||
for book in self.BOOK_ROWS:
|
||||
pages = book[KoReaderBookColumn.PAGES.value]
|
||||
pages_per_session = 20
|
||||
|
||||
start_time = book[KoReaderBookColumn.LAST_OPEN.value]
|
||||
end_session = False
|
||||
for page_num in range(
|
||||
1, book[KoReaderBookColumn.TOTAL_READ_PAGES.value] + 1
|
||||
):
|
||||
wiggle = random.randrange(5)
|
||||
self.PAGE_STATS_ROWS.append(
|
||||
[
|
||||
book[KoReaderBookColumn.ID.value],
|
||||
page_num,
|
||||
start_time,
|
||||
AVERAGE_PAGE_READING_SECONDS + wiggle,
|
||||
pages,
|
||||
]
|
||||
)
|
||||
if end_session:
|
||||
start_time += 3600 # one second over an hour, marking a new reading session
|
||||
end_session = False
|
||||
else:
|
||||
start_time += AVERAGE_PAGE_READING_SECONDS
|
||||
|
||||
if page_num % pages_per_session == 0:
|
||||
end_session = True
|
||||
|
||||
def __init__(self, book_count=0, **kwargs):
|
||||
self._generate_random_book_rows(book_count)
|
||||
self._generate_custom_book_row(**kwargs)
|
||||
self._generate_random_page_stats_rows()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def koreader_rows():
|
||||
return KoReaderBookRows(book_count=1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def demo_user():
|
||||
return User.objects.create(email="demo@example.com")
|
||||
54
vrobbler/apps/books/tests/test_koreader.py
Normal file
54
vrobbler/apps/books/tests/test_koreader.py
Normal file
@ -0,0 +1,54 @@
|
||||
import pytest
|
||||
from unittest import mock
|
||||
|
||||
from books.koreader import (
|
||||
KoReaderBookColumn,
|
||||
build_book_map,
|
||||
build_page_data,
|
||||
build_scrobbles_from_book_map,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch("requests.get")
|
||||
def test_build_book_map(get_mock, koreader_rows, valid_response):
|
||||
get_mock.return_value = valid_response
|
||||
book_map = build_book_map(koreader_rows.BOOK_ROWS)
|
||||
assert len(book_map) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch("requests.get")
|
||||
def test_load_page_data_to_map(get_mock, koreader_rows, valid_response):
|
||||
get_mock.return_value = valid_response
|
||||
book_map = build_page_data(
|
||||
koreader_rows.PAGE_STATS_ROWS,
|
||||
build_book_map(koreader_rows.BOOK_ROWS),
|
||||
)
|
||||
assert (
|
||||
len(book_map[1]["pages"])
|
||||
== koreader_rows.BOOK_ROWS[0][
|
||||
KoReaderBookColumn.TOTAL_READ_PAGES.value
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch("requests.get")
|
||||
def test_build_scrobbles_from_pages(
|
||||
get_mock, koreader_rows, demo_user, valid_response
|
||||
):
|
||||
get_mock.return_value = valid_response
|
||||
book_map = build_book_map(koreader_rows.BOOK_ROWS)
|
||||
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)
|
||||
|
||||
scrobbles = build_scrobbles_from_book_map(book_map, demo_user)
|
||||
# Corresponds to number of sessions per book ( 20 pages per session, 120 +/- 15 pages read )
|
||||
expected_scrobbles = 6 * len(book_map.keys())
|
||||
assert len(scrobbles) == expected_scrobbles
|
||||
assert len(scrobbles[0].logdata.page_data.keys()) == 21
|
||||
assert len(scrobbles[1].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[2].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[3].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[4].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[5].logdata.page_data.keys()) == 18
|
||||
36
vrobbler/apps/books/tests/test_openlibrary.py
Normal file
36
vrobbler/apps/books/tests/test_openlibrary.py
Normal file
@ -0,0 +1,36 @@
|
||||
from unittest import skip
|
||||
|
||||
import pytest
|
||||
|
||||
from books.openlibrary import lookup_book_from_openlibrary
|
||||
|
||||
|
||||
def test_lookup_modern_book():
|
||||
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
|
||||
assert book.get("title") == "Matrix"
|
||||
assert book.get("openlibrary_id") == "OL32170218M"
|
||||
assert book.get("ol_author_id") == "OL3675729A"
|
||||
|
||||
|
||||
def test_lookup_classic_book():
|
||||
book = lookup_book_from_openlibrary(
|
||||
"The Life of Castruccio Castracani", "Machiavelli"
|
||||
)
|
||||
assert book.get("title") == "The Life of Castruccio Castracani of Lucca"
|
||||
assert book.get("openlibrary_id") == "OL8950869M"
|
||||
assert book.get("ol_author_id") == "OL23135A"
|
||||
|
||||
|
||||
def test_lookup_foreign_book():
|
||||
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
|
||||
assert book.get("title") == "Ravage"
|
||||
assert book.get("openlibrary_id") == "OL8837839M"
|
||||
assert book.get("ol_author_id") == "OL152472A"
|
||||
|
||||
|
||||
@skip("This is rotten in OL, updated but waiting for it to update")
|
||||
def test_lookup_book():
|
||||
book = lookup_book_from_openlibrary("Hark! A Vagrant")
|
||||
assert book.get("title") == "Hark! A Vagrant"
|
||||
assert book.get("openlibrary_id") == "OL8837839M"
|
||||
assert book.get("ol_author_id") == "OL152472A"
|
||||
@ -1,52 +0,0 @@
|
||||
from django.core.files.base import ContentFile
|
||||
import requests
|
||||
from typing import Optional
|
||||
from books.openlibrary import (
|
||||
lookup_author_from_openlibrary,
|
||||
lookup_book_from_openlibrary,
|
||||
)
|
||||
from books.models import Author, Book
|
||||
|
||||
|
||||
def update_or_create_book(
|
||||
title: str, author: Optional[str] = None, force_update=False
|
||||
) -> Book:
|
||||
book_dict = lookup_book_from_openlibrary(title, author)
|
||||
|
||||
book, book_created = Book.objects.get_or_create(
|
||||
isbn=book_dict.get("isbn"),
|
||||
)
|
||||
|
||||
if book_created or force_update:
|
||||
cover_url = book_dict.pop("cover_url")
|
||||
ol_author_id = book_dict.pop("ol_author_id")
|
||||
|
||||
Book.objects.filter(pk=book.id).update(**book_dict)
|
||||
book.refresh_from_db()
|
||||
|
||||
# Process authors
|
||||
author_dict = lookup_author_from_openlibrary(ol_author_id)
|
||||
author = Author.objects.filter(openlibrary_id=ol_author_id).first()
|
||||
if not author:
|
||||
author_image_url = author_dict.pop("author_headshot_url", None)
|
||||
|
||||
author = Author.objects.create(**author_dict)
|
||||
|
||||
if author_image_url:
|
||||
r = requests.get(author_image_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{author.name}_{author.uuid}.jpg"
|
||||
author.headshot.save(
|
||||
fname, ContentFile(r.content), save=True
|
||||
)
|
||||
book.authors.add(author)
|
||||
|
||||
# Process cover URL
|
||||
r = requests.get(cover_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{book.title}_{book.uuid}.jpg"
|
||||
book.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
book.fix_metadata()
|
||||
|
||||
return book
|
||||
@ -1,15 +1,15 @@
|
||||
from django.views import generic
|
||||
from books.models import Book, Author
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
class BookListView(generic.ListView):
|
||||
|
||||
class BookListView(ScrobbleableListView):
|
||||
model = Book
|
||||
paginate_by = 20
|
||||
|
||||
|
||||
class BookDetailView(generic.DetailView):
|
||||
class BookDetailView(ScrobbleableDetailView):
|
||||
model = Book
|
||||
slug_field = "uuid"
|
||||
|
||||
|
||||
class AuthorDetailView(generic.DetailView):
|
||||
|
||||
0
vrobbler/apps/bricksets/__init__.py
Normal file
0
vrobbler/apps/bricksets/__init__.py
Normal file
23
vrobbler/apps/bricksets/admin.py
Normal file
23
vrobbler/apps/bricksets/admin.py
Normal file
@ -0,0 +1,23 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from bricksets.models import BrickSet
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
class BrickSetInline(admin.TabularInline):
|
||||
model = BrickSet
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(BrickSet)
|
||||
class BrickSetAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
5
vrobbler/apps/bricksets/apps.py
Normal file
5
vrobbler/apps/bricksets/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BrickSetsConfig(AppConfig):
|
||||
name = "bricksets"
|
||||
106
vrobbler/apps/bricksets/migrations/0001_initial.py
Normal file
106
vrobbler/apps/bricksets/migrations/0001_initial.py
Normal file
@ -0,0 +1,106 @@
|
||||
# Generated by Django 4.2.15 on 2024-09-07 05:38
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0059_remove_scrobble_book_koreader_hash_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BrickSet",
|
||||
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),
|
||||
),
|
||||
(
|
||||
"number",
|
||||
models.CharField(blank=True, max_length=10, null=True),
|
||||
),
|
||||
("release_year", models.IntegerField(blank=True, null=True)),
|
||||
("piece_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"brickset_rating",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=1, max_digits=3, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"lego_item_number",
|
||||
models.CharField(blank=True, max_length=10, null=True),
|
||||
),
|
||||
(
|
||||
"box_image",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="brickset/boxes/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"set_image",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="brickset/sets/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"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
vrobbler/apps/bricksets/migrations/__init__.py
Normal file
0
vrobbler/apps/bricksets/migrations/__init__.py
Normal file
63
vrobbler/apps/bricksets/models.py
Normal file
63
vrobbler/apps/bricksets/models.py
Normal file
@ -0,0 +1,63 @@
|
||||
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 BrickSetLogData
|
||||
from scrobbles.mixins import LongPlayScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class BrickSet(LongPlayScrobblableMixin):
|
||||
""""""
|
||||
|
||||
number = models.CharField(max_length=10, **BNULL)
|
||||
release_year = models.IntegerField(**BNULL)
|
||||
piece_count = models.IntegerField(**BNULL)
|
||||
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
|
||||
lego_item_number = models.CharField(max_length=10, **BNULL)
|
||||
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
|
||||
box_image_small = ImageSpecField(
|
||||
source="box_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
box_image_medium = ImageSpecField(
|
||||
source="box_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
set_image = models.ImageField(upload_to="brickset/sets/", **BNULL)
|
||||
set_image_small = ImageSpecField(
|
||||
source="set_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
set_image_medium = ImageSpecField(
|
||||
source="set_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BrickSetLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, title: str) -> "BrickSet":
|
||||
return cls.objects.filter(title=title).first()
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
if self.box_image:
|
||||
return self.box_image.url
|
||||
return ""
|
||||
14
vrobbler/apps/bricksets/urls.py
Normal file
14
vrobbler/apps/bricksets/urls.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
from bricksets import views
|
||||
|
||||
app_name = "bricksets"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("bricksets/", views.BrickSetListView.as_view(), name="brickset_list"),
|
||||
path(
|
||||
"bricksets/<slug:slug>/",
|
||||
views.BrickSetDetailView.as_view(),
|
||||
name="brickset_detail",
|
||||
),
|
||||
]
|
||||
10
vrobbler/apps/bricksets/views.py
Normal file
10
vrobbler/apps/bricksets/views.py
Normal file
@ -0,0 +1,10 @@
|
||||
from bricksets.models import BrickSet
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
|
||||
class BrickSetListView(ScrobbleableListView):
|
||||
model = BrickSet
|
||||
|
||||
|
||||
class BrickSetDetailView(ScrobbleableDetailView):
|
||||
model = BrickSet
|
||||
16
vrobbler/apps/lifeevents/admin.py
Normal file
16
vrobbler/apps/lifeevents/admin.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from lifeevents.models import LifeEvent
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(LifeEvent)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("title",)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
79
vrobbler/apps/lifeevents/migrations/0001_initial.py
Normal file
79
vrobbler/apps/lifeevents/migrations/0001_initial.py
Normal file
@ -0,0 +1,79 @@
|
||||
# Generated by Django 4.2.11 on 2024-05-07 13:37
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0055_rename_scrobble_log_scrobble_log"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="LifeEvent",
|
||||
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)),
|
||||
(
|
||||
"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
vrobbler/apps/lifeevents/migrations/__init__.py
Normal file
0
vrobbler/apps/lifeevents/migrations/__init__.py
Normal file
36
vrobbler/apps/lifeevents/models.py
Normal file
36
vrobbler/apps/lifeevents/models.py
Normal file
@ -0,0 +1,36 @@
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
import pendulum
|
||||
from scrobbles.dataclasses import LifeEventLogData
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class LifeEvent(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = 100
|
||||
|
||||
description = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"life-events:life-event_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return LifeEventLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, title: str) -> "LifeEvent":
|
||||
return cls.objects.filter(title=title).first()
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
return Scrobble.objects.filter(
|
||||
user_id=user_id, life_event=self
|
||||
).order_by("-timestamp")
|
||||
16
vrobbler/apps/lifeevents/urls.py
Normal file
16
vrobbler/apps/lifeevents/urls.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.urls import path
|
||||
from lifeevents import views
|
||||
|
||||
app_name = "lifeevents"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"lifeevents/", views.LifeEventListView.as_view(), name="lifeevent_list"
|
||||
),
|
||||
path(
|
||||
"lifeevent/<slug:slug>/",
|
||||
views.LifeEventDetailView.as_view(),
|
||||
name="life-event_detail",
|
||||
),
|
||||
]
|
||||
12
vrobbler/apps/lifeevents/views.py
Normal file
12
vrobbler/apps/lifeevents/views.py
Normal file
@ -0,0 +1,12 @@
|
||||
from django.views import generic
|
||||
from lifeevents.models import LifeEvent
|
||||
|
||||
|
||||
class LifeEventListView(generic.ListView):
|
||||
model = LifeEvent
|
||||
paginate_by = 20
|
||||
|
||||
|
||||
class LifeEventDetailView(generic.DetailView):
|
||||
model = LifeEvent
|
||||
slug_field = "uuid"
|
||||
0
vrobbler/apps/locations/__init__.py
Normal file
0
vrobbler/apps/locations/__init__.py
Normal file
21
vrobbler/apps/locations/admin.py
Normal file
21
vrobbler/apps/locations/admin.py
Normal file
@ -0,0 +1,21 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from locations.models import GeoLocation
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(GeoLocation)
|
||||
class GeoLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"created",
|
||||
"lat",
|
||||
"lon",
|
||||
"title",
|
||||
"altitude",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
6
vrobbler/apps/locations/apps.py
Normal file
6
vrobbler/apps/locations/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LocationConfig(AppConfig):
|
||||
name = "locations"
|
||||
|
||||
7
vrobbler/apps/locations/constants.py
Normal file
7
vrobbler/apps/locations/constants.py
Normal file
@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
LOCATION_PROVIDERS = defaultdict(lambda: "Unknown")
|
||||
LOCATION_PROVIDERS["gps"] = "GPS"
|
||||
LOCATION_PROVIDERS["network"] = "Wifi Triangulation"
|
||||
77
vrobbler/apps/locations/migrations/0001_initial.py
Normal file
77
vrobbler/apps/locations/migrations/0001_initial.py
Normal file
@ -0,0 +1,77 @@
|
||||
# Generated by Django 4.1.7 on 2023-11-21 23:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GeoLocation",
|
||||
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"
|
||||
),
|
||||
),
|
||||
(
|
||||
"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),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("lat", models.FloatField()),
|
||||
("lon", models.FloatField()),
|
||||
("altitude", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"genre",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("lat", "lon", "altitude")},
|
||||
},
|
||||
),
|
||||
]
|
||||
58
vrobbler/apps/locations/migrations/0002_rawgeolocation.py
Normal file
58
vrobbler/apps/locations/migrations/0002_rawgeolocation.py
Normal file
@ -0,0 +1,58 @@
|
||||
# Generated by Django 4.1.7 on 2023-11-22 00:13
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("locations", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RawGeoLocation",
|
||||
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"
|
||||
),
|
||||
),
|
||||
("lat", models.FloatField()),
|
||||
("lon", models.FloatField()),
|
||||
("altitude", models.FloatField(blank=True, null=True)),
|
||||
("speed", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-11-22 23:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("locations", "0002_rawgeolocation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="rawgeolocation",
|
||||
name="timestamp",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.7 on 2023-11-24 12:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("locations", "0003_rawgeolocation_timestamp"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="rawgeolocation",
|
||||
options={},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.1.7 on 2023-11-24 18:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("locations", "0004_alter_rawgeolocation_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="rawgeolocation",
|
||||
options={"get_latest_by": "modified"},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="geolocation",
|
||||
name="truncated_lat",
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="geolocation",
|
||||
name="truncated_lon",
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,16 @@
|
||||
# Generated by Django 4.2.9 on 2024-02-20 00:20
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("locations", "0005_alter_rawgeolocation_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="RawGeoLocation",
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/locations/migrations/__init__.py
Normal file
0
vrobbler/apps/locations/migrations/__init__.py
Normal file
132
vrobbler/apps/locations/models.py
Normal file
132
vrobbler/apps/locations/models.py
Normal file
@ -0,0 +1,132 @@
|
||||
from decimal import Decimal, getcontext
|
||||
import logging
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
User = get_user_model()
|
||||
|
||||
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
|
||||
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
|
||||
|
||||
|
||||
class GeoLocation(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
||||
|
||||
lat = models.FloatField()
|
||||
lon = models.FloatField()
|
||||
truncated_lat = models.FloatField(**BNULL)
|
||||
truncated_lon = models.FloatField(**BNULL)
|
||||
altitude = models.FloatField(**BNULL)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["lat", "lon", "altitude"]]
|
||||
|
||||
def __str__(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
|
||||
return f"{self.lat} x {self.lon}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"locations:geo_location_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, data_dict: Dict) -> "GeoLocation":
|
||||
"""Given a data dict from GPSLogger, does the heavy lifting of looking up
|
||||
the location, creating if if doesn't exist yet.
|
||||
|
||||
"""
|
||||
# TODO Add constants for all these data keys
|
||||
if "lat" not in data_dict.keys() or "lon" not in data_dict.keys():
|
||||
logger.error("No lat or lon keys in data dict")
|
||||
return
|
||||
|
||||
int_lat, r_lat = str(data_dict.get("lat", "")).split(".")
|
||||
int_lon, r_lon = str(data_dict.get("lon", "")).split(".")
|
||||
|
||||
try:
|
||||
trunc_lat = r_lat[0:GEOLOC_ACCURACY]
|
||||
except IndexError:
|
||||
trunc_lat = r_lat
|
||||
try:
|
||||
trunc_lon = r_lon[0:GEOLOC_ACCURACY]
|
||||
except IndexError:
|
||||
trunc_lon = r_lon
|
||||
|
||||
data_dict["lat"] = float(f"{int_lat}.{trunc_lat}")
|
||||
data_dict["lon"] = float(f"{int_lon}.{trunc_lon}")
|
||||
|
||||
int_alt, r_alt = str(data_dict.get("alt", "")).split(".")
|
||||
|
||||
data_dict["altitude"] = float(int_alt)
|
||||
|
||||
location = cls.objects.filter(
|
||||
lat=data_dict.get("lat"),
|
||||
lon=data_dict.get("lon"),
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
location = cls.objects.create(
|
||||
lat=data_dict.get("lat"),
|
||||
lon=data_dict.get("lon"),
|
||||
altitude=data_dict.get("altitude"),
|
||||
)
|
||||
return location
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
if self.title:
|
||||
return f"{self.lat} x {self.lon}"
|
||||
return ""
|
||||
|
||||
def loc_diff(self, old_lat_lon: tuple) -> tuple:
|
||||
return (
|
||||
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
|
||||
abs(Decimal(old_lat_lon[1]) - Decimal(self.lon)),
|
||||
)
|
||||
|
||||
def has_moved(self, previous_location: "GeoLocation") -> bool:
|
||||
has_moved = False
|
||||
|
||||
loc_diff = self.loc_diff(
|
||||
(previous_location.lat, previous_location.lon)
|
||||
)
|
||||
if loc_diff[0] > GEOLOC_PROXIMITY or loc_diff[1] > GEOLOC_PROXIMITY:
|
||||
has_moved = True
|
||||
logger.debug(
|
||||
f"[locations] checked whether location has moved against proximity setting",
|
||||
extra={
|
||||
"location_id": self.id,
|
||||
"loc_diff": loc_diff,
|
||||
"has_moved": has_moved,
|
||||
"previous_location_id": previous_location.id,
|
||||
"geoloc_proximity": GEOLOC_PROXIMITY,
|
||||
},
|
||||
)
|
||||
return has_moved
|
||||
|
||||
def in_proximity(self, named=False) -> models.QuerySet:
|
||||
lat_min = Decimal(self.lat) - GEOLOC_PROXIMITY
|
||||
lat_max = Decimal(self.lat) + GEOLOC_PROXIMITY
|
||||
lon_min = Decimal(self.lon) - GEOLOC_PROXIMITY
|
||||
lon_max = Decimal(self.lon) + GEOLOC_PROXIMITY
|
||||
is_title_null = not named
|
||||
close_locations = GeoLocation.objects.filter(
|
||||
title__isnull=is_title_null,
|
||||
lat__lte=lat_max,
|
||||
lat__gte=lat_min,
|
||||
lon__lte=lon_max,
|
||||
lon__gte=lon_min,
|
||||
).exclude(id=self.id)
|
||||
return close_locations
|
||||
90
vrobbler/apps/locations/tests/test_models.py
Normal file
90
vrobbler/apps/locations/tests/test_models.py
Normal file
@ -0,0 +1,90 @@
|
||||
import pytest
|
||||
import logging
|
||||
|
||||
from locations.models import GeoLocation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_find_or_create(caplog):
|
||||
assert not GeoLocation.find_or_create({})
|
||||
assert "No lat or lon keys in data dict" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_find_or_create_truncation():
|
||||
loc = GeoLocation.find_or_create(
|
||||
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
|
||||
)
|
||||
assert loc.lat == 44.234
|
||||
assert loc.lon == -68.234
|
||||
assert loc.altitude == 60
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_find_or_create_finds_existing():
|
||||
extant = GeoLocation.objects.create(lat=44.234, lon=-68.234, altitude=50)
|
||||
|
||||
loc = GeoLocation.find_or_create(
|
||||
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
|
||||
)
|
||||
assert loc.id == extant.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_find_or_create_creates_new():
|
||||
extant = GeoLocation.objects.create(lat=44.234, lon=-69.234, altitude=60)
|
||||
|
||||
loc = GeoLocation.find_or_create(
|
||||
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
|
||||
)
|
||||
assert not loc.id == extant.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_found_in_proximity_location():
|
||||
lat = 44.234
|
||||
lon = -69.234
|
||||
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
|
||||
|
||||
close = GeoLocation.objects.create(
|
||||
lat=lat + 0.0001, lon=lon - 0.0001, altitude=60
|
||||
)
|
||||
assert close not in loc.in_proximity(named=True)
|
||||
assert close in loc.in_proximity()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_not_found_in_proximity_location():
|
||||
lat = 44.234
|
||||
lon = -69.234
|
||||
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
|
||||
|
||||
far = GeoLocation.objects.create(
|
||||
lat=lat + 0.0002, lon=lon - 0.0001, altitude=60
|
||||
)
|
||||
assert far not in loc.in_proximity()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_has_moved(caplog):
|
||||
lat = 44.234
|
||||
lon = -69.234
|
||||
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
|
||||
|
||||
past = GeoLocation.objects.get_or_create(
|
||||
lat=lat + 0.0009, lon=lon - 0.002, altitude=60
|
||||
)[0]
|
||||
assert loc.has_moved(past)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_has_not_moved():
|
||||
lat = 44.234
|
||||
lon = -69.234
|
||||
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
|
||||
|
||||
past = GeoLocation.objects.get_or_create(
|
||||
lat=lat + 0.00009, lon=lon - 0.00009, altitude=60
|
||||
)[0]
|
||||
assert not loc.has_moved(past)
|
||||
18
vrobbler/apps/locations/urls.py
Normal file
18
vrobbler/apps/locations/urls.py
Normal file
@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
from locations import views
|
||||
|
||||
app_name = "locations"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"locations/",
|
||||
views.GeoLocationListView.as_view(),
|
||||
name="geo_locations_list",
|
||||
),
|
||||
path(
|
||||
"locations/<slug:slug>/",
|
||||
views.GeoLocationDetailView.as_view(),
|
||||
name="geo_location_detail",
|
||||
),
|
||||
]
|
||||
22
vrobbler/apps/locations/views.py
Normal file
22
vrobbler/apps/locations/views.py
Normal file
@ -0,0 +1,22 @@
|
||||
from django.db.models import Count
|
||||
from django.views import generic
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.stats import get_scrobble_count_qs
|
||||
|
||||
|
||||
class GeoLocationListView(generic.ListView):
|
||||
model = GeoLocation
|
||||
paginate_by = 75
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(scrobble__user_id=self.request.user.id).order_by("-scrobble__timestamp")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["latest"] = self.get_queryset().first()
|
||||
return context_data
|
||||
|
||||
|
||||
class GeoLocationDetailView(generic.DetailView):
|
||||
model = GeoLocation
|
||||
slug_field = "uuid"
|
||||
0
vrobbler/apps/moods/__init__.py
Normal file
0
vrobbler/apps/moods/__init__.py
Normal file
23
vrobbler/apps/moods/admin.py
Normal file
23
vrobbler/apps/moods/admin.py
Normal file
@ -0,0 +1,23 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from moods.models import Mood
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
class MoodInline(admin.TabularInline):
|
||||
model = Mood
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Mood)
|
||||
class MoodAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
5
vrobbler/apps/moods/apps.py
Normal file
5
vrobbler/apps/moods/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MoodsConfig(AppConfig):
|
||||
name = "moods"
|
||||
1
vrobbler/apps/moods/fixtures/moods.json
Normal file
1
vrobbler/apps/moods/fixtures/moods.json
Normal file
File diff suppressed because one or more lines are too long
79
vrobbler/apps/moods/migrations/0001_initial.py
Normal file
79
vrobbler/apps/moods/migrations/0001_initial.py
Normal file
@ -0,0 +1,79 @@
|
||||
# Generated by Django 4.2.13 on 2024-08-10 20:01
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Mood",
|
||||
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)),
|
||||
(
|
||||
"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,
|
||||
},
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/moods/migrations/0002_mood_image.py
Normal file
18
vrobbler/apps/moods/migrations/0002_mood_image.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.13 on 2024-08-10 20:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moods", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="mood",
|
||||
name="image",
|
||||
field=models.ImageField(blank=True, null=True, upload_to="moods/"),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/moods/migrations/__init__.py
Normal file
0
vrobbler/apps/moods/migrations/__init__.py
Normal file
56
vrobbler/apps/moods/models.py
Normal file
56
vrobbler/apps/moods/models.py
Normal file
@ -0,0 +1,56 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Mood(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
image = models.ImageField(upload_to="moods/", **BNULL)
|
||||
image_small = ImageSpecField(
|
||||
source="thumbnail",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
image_medium = ImageSpecField(
|
||||
source="thumbnail",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
return str(self.uuid)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def get_start_url(self):
|
||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return MoodLogData
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
if self.image:
|
||||
return self.image.url
|
||||
return ""
|
||||
14
vrobbler/apps/moods/urls.py
Normal file
14
vrobbler/apps/moods/urls.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
from moods import views
|
||||
|
||||
app_name = "moods"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("mood/", views.MoodListView.as_view(), name="mood-list"),
|
||||
path(
|
||||
"mood/<slug:slug>/",
|
||||
views.MoodDetailView.as_view(),
|
||||
name="mood-detail",
|
||||
),
|
||||
]
|
||||
10
vrobbler/apps/moods/views.py
Normal file
10
vrobbler/apps/moods/views.py
Normal file
@ -0,0 +1,10 @@
|
||||
from moods.models import Mood
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
|
||||
class MoodListView(ScrobbleableListView):
|
||||
model = Mood
|
||||
|
||||
|
||||
class MoodDetailView(ScrobbleableDetailView):
|
||||
model = Mood
|
||||
@ -26,7 +26,9 @@ def scrobble_counts(user=None):
|
||||
starting_day_of_current_year = now.date().replace(month=1, day=1)
|
||||
|
||||
finished_scrobbles_qs = Scrobble.objects.filter(
|
||||
user_filter, played_to_completion=True
|
||||
user_filter,
|
||||
played_to_completion=True,
|
||||
media_type=Scrobble.MediaType.TRACK,
|
||||
)
|
||||
data = {}
|
||||
data["today"] = finished_scrobbles_qs.filter(
|
||||
|
||||
@ -1,3 +1,78 @@
|
||||
jellyfin_keys = [
|
||||
"ServerId",
|
||||
"ServerName",
|
||||
"ServerVersion",
|
||||
"ServerUrl",
|
||||
"NotificationType",
|
||||
"Timestamp",
|
||||
"UtcTimestamp",
|
||||
"Name",
|
||||
"Overview",
|
||||
"Tagline",
|
||||
"ItemId",
|
||||
"RunTimeTicks",
|
||||
"RunTime",
|
||||
"Year",
|
||||
"Provider_tmdb",
|
||||
"Provider_imdb",
|
||||
"Provider_tmdbcollection",
|
||||
"Provider_tmdbset",
|
||||
"Video_0_Title",
|
||||
"Video_0_Type",
|
||||
"Video_0_Codec",
|
||||
"Video_0_Profile",
|
||||
"Video_0_Level",
|
||||
"Video_0_Height",
|
||||
"Video_0_Width",
|
||||
"Video_0_AspectRatio",
|
||||
"Video_0_Interlaced",
|
||||
"Video_0_FrameRate",
|
||||
"Video_0_VideoRange",
|
||||
"Video_0_ColorSpace",
|
||||
"Video_0_ColorTransfer",
|
||||
"Video_0_ColorPrimaries",
|
||||
"Video_0_PixelFormat",
|
||||
"Video_0_RefFrames",
|
||||
"Audio_0_Title",
|
||||
"Audio_0_Type",
|
||||
"Audio_0_Language",
|
||||
"Audio_0_Codec",
|
||||
"Audio_0_Channels",
|
||||
"Audio_0_Bitrate",
|
||||
"Audio_0_SampleRate",
|
||||
"Audio_0_Default",
|
||||
"Subtitle_0_Title",
|
||||
"Subtitle_0_Type",
|
||||
"Subtitle_0_Language",
|
||||
"Subtitle_0_Codec",
|
||||
"Subtitle_0_Default",
|
||||
"Subtitle_0_Forced",
|
||||
"Subtitle_0_External",
|
||||
"Subtitle_1_Title",
|
||||
"Subtitle_1_Type",
|
||||
"Subtitle_1_Language",
|
||||
"Subtitle_1_Codec",
|
||||
"Subtitle_1_Default",
|
||||
"Subtitle_1_Forced",
|
||||
"Subtitle_1_External",
|
||||
"Subtitle_2_Title",
|
||||
"Subtitle_2_Type",
|
||||
"Subtitle_2_Language",
|
||||
"Subtitle_2_Codec",
|
||||
"Subtitle_2_Default",
|
||||
"Subtitle_2_Forced",
|
||||
"Subtitle_2_External",
|
||||
"PlaybackPositionTicks",
|
||||
"PlaybackPosition",
|
||||
"MediaSourceId",
|
||||
"IsPaused",
|
||||
"IsAutomated",
|
||||
"DeviceId",
|
||||
"DeviceName",
|
||||
"ClientName",
|
||||
"NotificationUsername",
|
||||
"UserId",
|
||||
]
|
||||
VARIOUS_ARTIST_DICT = {
|
||||
"name": "Various Artists",
|
||||
"theaudiodb_id": "113641",
|
||||
@ -6,10 +81,13 @@ VARIOUS_ARTIST_DICT = {
|
||||
|
||||
JELLYFIN_POST_KEYS = {
|
||||
"ITEM_TYPE": "ItemType",
|
||||
"ITEM_ID": "ItemId",
|
||||
"RUN_TIME": "RunTime",
|
||||
"TITLE": "Name",
|
||||
"TRACK_TITLE": "Name",
|
||||
"TIMESTAMP": "UtcTimestamp",
|
||||
"YEAR": "Year",
|
||||
"OVERVIEW": "Overview",
|
||||
"TAGLINE": "TAGLINE",
|
||||
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
|
||||
"PLAYBACK_POSITION": "PlaybackPosition",
|
||||
"ARTIST_MB_ID": "Provider_musicbrainzartist",
|
||||
@ -18,4 +96,25 @@ JELLYFIN_POST_KEYS = {
|
||||
"TRACK_MB_ID": "Provider_musicbrainztrack",
|
||||
"ALBUM_NAME": "Album",
|
||||
"ARTIST_NAME": "Artist",
|
||||
"STATUS": "Status",
|
||||
"SOURCE": "ClientName",
|
||||
"VIDEO_TITLE": "Name",
|
||||
"IMDB_ID": "Provider_imdb",
|
||||
"TMDB_ID": "Provider_tmdb",
|
||||
}
|
||||
|
||||
MOPIDY_POST_KEYS = {
|
||||
"ITEM_TYPE": None,
|
||||
"RUN_TIME": "run_time",
|
||||
"TRACK_TITLE": "name",
|
||||
"TIMESTAMP": None,
|
||||
"YEAR": None,
|
||||
"PLAYBACK_POSITION_TICKS": "playback_time_ticks",
|
||||
"ARTIST_MB_ID": "muscibrainz_artist_id",
|
||||
"ALBUM_MB_ID": "musicbrainz_album_id",
|
||||
"RELEASEGROUP_MB_ID": None,
|
||||
"TRACK_MB_ID": "musicbrainz_track_id",
|
||||
"ALBUM_NAME": "album",
|
||||
"ARTIST_NAME": "artist",
|
||||
"STATUS": "status",
|
||||
}
|
||||
|
||||
@ -46,7 +46,6 @@ class LastFM:
|
||||
|
||||
new_scrobbles = []
|
||||
source = "Last.fm"
|
||||
source_id = ""
|
||||
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
||||
|
||||
for lfm_scrobble in lastfm_scrobbles:
|
||||
@ -60,14 +59,19 @@ class LastFM:
|
||||
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,
|
||||
source_id=source_id,
|
||||
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)
|
||||
@ -107,6 +111,7 @@ class LastFM:
|
||||
# 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
|
||||
@ -121,6 +126,10 @@ class LastFM:
|
||||
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}")
|
||||
|
||||
26
vrobbler/apps/music/migrations/0023_alter_track_genre.py
Normal file
26
vrobbler/apps/music/migrations/0023_alter_track_genre.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-18 02:33
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0040_alter_scrobble_media_type"),
|
||||
("music", "0022_artist_theaudiodb_id"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="track",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -12,6 +12,8 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
|
||||
from music.bandcamp import get_bandcamp_slug
|
||||
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
|
||||
@ -32,6 +34,18 @@ class Artist(TimeStampedModel):
|
||||
allmusic_id = models.CharField(max_length=100, **BNULL)
|
||||
bandcamp_id = models.CharField(max_length=100, **BNULL)
|
||||
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
|
||||
thumbnail_small = ImageSpecField(
|
||||
source="thumbnail",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
thumbnail_medium = ImageSpecField(
|
||||
source="thumbnail",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["name", "musicbrainz_id"]]
|
||||
@ -39,6 +53,14 @@ class Artist(TimeStampedModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
if self.thumbnail:
|
||||
return self.thumbnail.url
|
||||
if self.album_set.first().cover_image:
|
||||
return self.album_set.first().cover_image.url
|
||||
return ""
|
||||
|
||||
@property
|
||||
def mb_link(self):
|
||||
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
||||
@ -96,10 +118,18 @@ class Artist(TimeStampedModel):
|
||||
self.bandcamp_id = slug
|
||||
self.save(update_fields=["bandcamp_id"])
|
||||
|
||||
def fix_metadata(self):
|
||||
tadb_info = lookup_artist_from_tadb(self.name)
|
||||
def fix_metadata(self, force_update=False):
|
||||
tadb_info = {}
|
||||
if self.theaudiodb_id and force_update:
|
||||
tadb_info = lookup_artist_from_tadb(self.theaudiodb_id)
|
||||
|
||||
if not self.theaudiodb_id or (force_update and not tadb_info):
|
||||
tadb_info = lookup_artist_from_tadb(self.name)
|
||||
|
||||
if not tadb_info:
|
||||
logger.warn(f"No response from TADB for artist {self.name}")
|
||||
logger.warn(
|
||||
f"No response from TADB for artist {self.name}, try force_update=True"
|
||||
)
|
||||
return
|
||||
|
||||
self.biography = tadb_info["biography"]
|
||||
@ -136,6 +166,18 @@ class Album(TimeStampedModel):
|
||||
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
|
||||
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
|
||||
cover_image = models.ImageField(upload_to="albums/", **BNULL)
|
||||
cover_image_small = ImageSpecField(
|
||||
source="cover_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
cover_image_medium = ImageSpecField(
|
||||
source="cover_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
|
||||
theaudiodb_description = models.TextField(**BNULL)
|
||||
theaudiodb_year_released = models.IntegerField(**BNULL)
|
||||
@ -168,6 +210,12 @@ class Album(TimeStampedModel):
|
||||
track__in=self.track_set.all()
|
||||
).order_by("-timestamp")
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
if self.cover_image:
|
||||
return self.cover_image_medium.url
|
||||
return ""
|
||||
|
||||
@property
|
||||
def tracks(self):
|
||||
return (
|
||||
@ -356,7 +404,7 @@ class Album(TimeStampedModel):
|
||||
|
||||
|
||||
class Track(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 90)
|
||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
||||
|
||||
class Opinion(models.IntegerChoices):
|
||||
DOWN = -1, "Thumbs down"
|
||||
@ -392,9 +440,9 @@ class Track(ScrobblableMixin):
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.artist.thumbnail:
|
||||
url = self.artist.thumbnail.url
|
||||
url = self.artist.thumbnail_medium.url
|
||||
if self.album and self.album.cover_image:
|
||||
url = self.album.cover_image.url
|
||||
url = self.album.cover_image_medium.url
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
import musicbrainzngs
|
||||
from dateutil.parser import parse
|
||||
@ -52,10 +53,18 @@ def lookup_album_from_mb(musicbrainz_id: str) -> dict:
|
||||
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
|
||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||
|
||||
top_result = musicbrainzngs.search_releases(
|
||||
release_name, artist=artist_name
|
||||
)["release-list"][0]
|
||||
score = int(top_result.get("ext:score"))
|
||||
top_result = {}
|
||||
|
||||
try:
|
||||
top_result = musicbrainzngs.search_releases(
|
||||
release_name, artist=artist_name
|
||||
)["release-list"][0]
|
||||
except IndexError:
|
||||
logger.info(
|
||||
f"No release found on MB for {artist_name} and {release_name}"
|
||||
)
|
||||
|
||||
score = int(top_result.get("ext:score", 0))
|
||||
if score < 85:
|
||||
logger.debug(
|
||||
"Album lookup score below 85 threshold",
|
||||
@ -74,37 +83,58 @@ def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def lookup_artist_from_mb(artist_name: str) -> str:
|
||||
def lookup_artist_from_mb(artist_name: str) -> dict:
|
||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||
|
||||
top_result = musicbrainzngs.search_artists(artist=artist_name)[
|
||||
"artist-list"
|
||||
][0]
|
||||
try:
|
||||
top_result = musicbrainzngs.search_artists(artist=artist_name)[
|
||||
"artist-list"
|
||||
][0]
|
||||
except IndexError:
|
||||
return {}
|
||||
score = int(top_result.get("ext:score"))
|
||||
if score < 85:
|
||||
logger.debug(
|
||||
"Artist lookup score below 85 threshold",
|
||||
extra={"result": top_result},
|
||||
)
|
||||
return ""
|
||||
return {}
|
||||
|
||||
return top_result
|
||||
|
||||
|
||||
def lookup_track_from_mb(
|
||||
track_name: str, artist_mbid: str, album_mbid: str
|
||||
) -> str:
|
||||
track_name: str, artist_mb_id: str, album_mb_id: str
|
||||
) -> dict:
|
||||
logger.info(
|
||||
"[lookup_track_from_mb] called",
|
||||
extra={
|
||||
"track_name": track_name,
|
||||
"artist_mb_id": artist_mb_id,
|
||||
"album_mb_id": album_mb_id,
|
||||
},
|
||||
)
|
||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||
|
||||
top_result = musicbrainzngs.search_recordings(
|
||||
query=track_name, artist=artist_mbid, release=album_mbid
|
||||
)["recording-list"][0]
|
||||
try:
|
||||
results = musicbrainzngs.search_recordings(
|
||||
query=track_name, artist=artist_mb_id, release=album_mb_id
|
||||
)["recording-list"]
|
||||
logger.info(
|
||||
"[lookup_track_from_mb] musicbrainz recordings search results",
|
||||
extra={"results": results},
|
||||
)
|
||||
top_result = results[0]
|
||||
except IndexError:
|
||||
logger.error("[lookup_track_from_mb] no results found")
|
||||
return {}
|
||||
|
||||
score = int(top_result.get("ext:score"))
|
||||
if score < 85:
|
||||
logger.debug(
|
||||
"Track lookup score below 85 threshold",
|
||||
extra={"result": top_result},
|
||||
logger.info(
|
||||
"[lookup_track_from_mb] no results above 85% certainty ",
|
||||
extra={"results": results},
|
||||
)
|
||||
return ""
|
||||
return {}
|
||||
|
||||
return top_result
|
||||
|
||||
@ -7,23 +7,48 @@ from django.conf import settings
|
||||
|
||||
THEAUDIODB_API_KEY = getattr(settings, "THEAUDIODB_API_KEY")
|
||||
ARTIST_SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/search.php?s="
|
||||
ARTIST_FETCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/artist.php?i="
|
||||
ALBUM_SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/searchalbum.php?s="
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def lookup_artist_from_tadb(name: str) -> dict:
|
||||
def lookup_artist_from_tadb(name_or_id: str) -> dict:
|
||||
artist_info = {}
|
||||
name = urllib.parse.quote(name)
|
||||
response = requests.get(ARTIST_SEARCH_URL + name)
|
||||
response = None
|
||||
|
||||
# Look for an ID as an integer
|
||||
try:
|
||||
tadb_id = int(name_or_id)
|
||||
except ValueError:
|
||||
tadb_id = None
|
||||
|
||||
if tadb_id:
|
||||
response = requests.get(ARTIST_FETCH_URL + str(tadb_id))
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warn(f"Bad response from TADB: {response.status_code}")
|
||||
return artist_info
|
||||
|
||||
if not response.content:
|
||||
logger.warn(f"Bad content from TADB: {response.content}")
|
||||
return artist_info
|
||||
|
||||
if not response:
|
||||
name = urllib.parse.quote(name_or_id)
|
||||
response = requests.get(ARTIST_SEARCH_URL + name)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warn(f"Bad response from TADB: {response.status_code}")
|
||||
return {}
|
||||
return artist_info
|
||||
|
||||
if not response.content:
|
||||
logger.warn(f"Bad content from TADB: {response.content}")
|
||||
return {}
|
||||
return artist_info
|
||||
|
||||
if '{"artists": null}' in str(response.content):
|
||||
logger.warn(f"Bad content from TADB: {response.content}")
|
||||
return artist_info
|
||||
|
||||
results = json.loads(response.content)
|
||||
if results["artists"]:
|
||||
|
||||
@ -8,6 +8,7 @@ from music.musicbrainz import (
|
||||
lookup_track_from_mb,
|
||||
)
|
||||
from music.constants import VARIOUS_ARTIST_DICT
|
||||
from scrobbles.utils import convert_to_seconds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -26,9 +27,10 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
|
||||
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||
|
||||
artist_dict = lookup_artist_from_mb(name)
|
||||
mbid = mbid or artist_dict["id"]
|
||||
mbid = mbid or artist_dict.get("id", None)
|
||||
|
||||
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||
if mbid:
|
||||
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||
if not artist:
|
||||
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
|
||||
artist.fix_metadata()
|
||||
@ -42,20 +44,34 @@ def get_or_create_album(
|
||||
album = None
|
||||
album_dict = lookup_album_dict_from_mb(name, artist_name=artist.name)
|
||||
|
||||
name = name or album_dict["title"]
|
||||
album = Album.objects.filter(artists__in=[artist], name=name).first()
|
||||
name = name or album_dict.get("title", None)
|
||||
if not name:
|
||||
logger.debug(
|
||||
f"Cannot get or create album by {artist} with no name ({name})"
|
||||
)
|
||||
return
|
||||
|
||||
album = Album.objects.filter(
|
||||
musicbrainz_id=mbid, name=name, artists__in=[artist]
|
||||
).first()
|
||||
|
||||
if not album:
|
||||
mbid_group = album_dict.get("mb_group_id")
|
||||
album = Album.objects.filter(
|
||||
musicbrainz_releasegroup_id=mbid_group
|
||||
).first()
|
||||
|
||||
if not album and name:
|
||||
mbid = mbid or album_dict["mb_id"]
|
||||
album, album_created = Album.objects.get_or_create(
|
||||
name=name, musicbrainz_id=mbid
|
||||
)
|
||||
album, album_created = Album.objects.get_or_create(musicbrainz_id=mbid)
|
||||
if album_created:
|
||||
album.name = name
|
||||
album.year = album_dict["year"]
|
||||
album.musicbrainz_releasegroup_id = album_dict["mb_group_id"]
|
||||
album.musicbrainz_albumartist_id = artist.musicbrainz_id
|
||||
album.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
"musicbrainz_id",
|
||||
"year",
|
||||
"musicbrainz_releasegroup_id",
|
||||
@ -74,32 +90,55 @@ def get_or_create_album(
|
||||
return album
|
||||
|
||||
|
||||
def get_or_create_track(
|
||||
title: str,
|
||||
artist: Artist,
|
||||
album: Album,
|
||||
mbid: str = None,
|
||||
run_time_seconds=None,
|
||||
) -> Track:
|
||||
track = None
|
||||
if not mbid:
|
||||
mbid = lookup_track_from_mb(
|
||||
title,
|
||||
artist.musicbrainz_id,
|
||||
album.musicbrainz_id,
|
||||
)["id"]
|
||||
def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
|
||||
try:
|
||||
track_run_time_seconds = int(
|
||||
post_data.get(post_keys.get("RUN_TIME"), 0)
|
||||
)
|
||||
except ValueError: # Sometimes we get run time as a string like "01:35"
|
||||
track_run_time_seconds = convert_to_seconds(
|
||||
post_data.get(post_keys.get("RUN_TIME"), 0)
|
||||
)
|
||||
|
||||
track = Track.objects.filter(musicbrainz_id=mbid).first()
|
||||
artist_name = post_data.get(post_keys.get("ARTIST_NAME"), "")
|
||||
artist_mb_id = post_data.get(post_keys.get("ARTIST_MB_ID"), "")
|
||||
album_title = post_data.get(post_keys.get("ALBUM_NAME"), "")
|
||||
album_mb_id = post_data.get(post_keys.get("ALBUM_MB_ID"), "")
|
||||
track_title = post_data.get(post_keys.get("TRACK_TITLE"), "")
|
||||
track_mb_id = post_data.get(post_keys.get("TRACK_MB_ID"), "")
|
||||
|
||||
artist = get_or_create_artist(
|
||||
artist_name,
|
||||
mbid=artist_mb_id,
|
||||
)
|
||||
album = get_or_create_album(
|
||||
album_title,
|
||||
artist=artist,
|
||||
mbid=album_mb_id,
|
||||
)
|
||||
|
||||
track = None
|
||||
if not track_mb_id and album:
|
||||
try:
|
||||
track_mb_id = lookup_track_from_mb(
|
||||
track_title,
|
||||
artist.musicbrainz_id,
|
||||
album.musicbrainz_id,
|
||||
).get("id", 0)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
if track_mb_id:
|
||||
track = Track.objects.filter(musicbrainz_id=track_mb_id).first()
|
||||
|
||||
if not track:
|
||||
track = Track.objects.create(
|
||||
title=title,
|
||||
title=track_title,
|
||||
artist=artist,
|
||||
album=album,
|
||||
musicbrainz_id=mbid,
|
||||
run_time_seconds=run_time_seconds,
|
||||
musicbrainz_id=track_mb_id,
|
||||
run_time_seconds=track_run_time_seconds,
|
||||
)
|
||||
|
||||
return track
|
||||
|
||||
|
||||
|
||||
@ -4,20 +4,15 @@ from music.models import Album, Artist, Track
|
||||
from scrobbles.models import ChartRecord
|
||||
from scrobbles.stats import get_scrobble_count_qs
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
class TrackListView(generic.ListView):
|
||||
|
||||
class TrackListView(ScrobbleableListView):
|
||||
model = Track
|
||||
paginate_by = 200
|
||||
|
||||
def get_queryset(self):
|
||||
return get_scrobble_count_qs(user=self.request.user).order_by(
|
||||
"-scrobble_count"
|
||||
)
|
||||
|
||||
|
||||
class TrackDetailView(generic.DetailView):
|
||||
class TrackDetailView(ScrobbleableDetailView):
|
||||
model = Track
|
||||
slug_field = "uuid"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from podcasts.models import Episode, Podcast, Producer
|
||||
from podcasts.models import PodcastEpisode, Podcast, Producer
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@ -21,8 +21,8 @@ class PodcastAdmin(admin.ModelAdmin):
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Episode)
|
||||
class EpisodeAdmin(admin.ModelAdmin):
|
||||
@admin.register(PodcastEpisode)
|
||||
class PodcastEpisodeAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-18 02:33
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0040_alter_scrobble_media_type"),
|
||||
("podcasts", "0011_rename_url_podcast_feed_url_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="episode",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user