Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ba8a48ac | |||
| 1530de3188 | |||
| 2d235c0577 | |||
| b0eb58953b | |||
| 7309181fed | |||
| 971fee5b4b | |||
| 920a9180c8 | |||
| d568a377f0 | |||
| 3851624dd7 | |||
| 8c865fe008 | |||
| 572dbf7a88 | |||
| 7addd50577 | |||
| cd5dc25642 | |||
| 9c2355978e | |||
| 4b9b785e50 | |||
| 050b2b9d77 | |||
| d12cca304f | |||
| 8603bbd5cb | |||
| 749e74a54c | |||
| 7b3692ef7b | |||
| c49f6a1740 | |||
| 1d813e4643 | |||
| 5e0a429d81 | |||
| d928d266b9 | |||
| b4dbbb4211 | |||
| dcb5260cfc | |||
| a8747dfe77 | |||
| a474b5df48 | |||
| 082979bea6 | |||
| 1275186d86 | |||
| cd60ac6387 | |||
| bdfbd3e5c0 | |||
| dff63f325f | |||
| 2b634e3b7e | |||
| 723d739405 | |||
| e62a07af37 | |||
| f86c3b2935 | |||
| 050add8543 | |||
| 8faf0296a6 | |||
| f209f3b107 | |||
| b233b60ae0 | |||
| e1d4a7c5a4 | |||
| 59e8339e94 | |||
| 9277db97e5 | |||
| e755dc6641 | |||
| 782f5c15d6 | |||
| 2f4fae7d02 | |||
| 4b7c5aa58d | |||
| d4f82f2d6f | |||
| 106d25c20f | |||
| d77caa2783 | |||
| b5bfad73ef | |||
| 274b2704ed | |||
| 80fcb6c002 | |||
| c6f3c90006 | |||
| 387dee7d37 | |||
| 188e899357 | |||
| 30b005fa46 | |||
| 72f739ee5a | |||
| 56ee14512d | |||
| 8c947d35dd | |||
| 61bab1f734 | |||
| 42ce6df9bd | |||
| cbd46df4bc | |||
| e7203cdb9b | |||
| 7246adfeb6 | |||
| a5606951c5 | |||
| 0b4537b7ed | |||
| 6306390f82 | |||
| 350d3ceb14 | |||
| a1ff82bfec | |||
| 92c0c668b3 | |||
| 3b77feda45 | |||
| 45c402f8c1 | |||
| 90a1398438 | |||
| c7a81802ac | |||
| a9a8678ac0 | |||
| cbf0583871 | |||
| 5cac1fe109 | |||
| 6782ed312d | |||
| fda505ea4e | |||
| 8db111f66f | |||
| ee1cae496a | |||
| 9403c68184 | |||
| 96030f4a99 | |||
| a8c3925af4 | |||
| a2f507a976 | |||
| 7a7edc6e47 | |||
| af6c39fb85 | |||
| 36cfdd6f6c | |||
| b11d87af75 | |||
| 1cf50209a4 | |||
| 8a5486fb2c | |||
| 135d6e65fa | |||
| 965f2dd41b | |||
| 1a1de02843 | |||
| a1868e7b2c | |||
| 52494651bf | |||
| 1093aa2376 | |||
| d1f04c15a9 | |||
| fd3487c225 | |||
| df91526b0c | |||
| 70f103db6f | |||
| b0b32821e3 | |||
| 278cab32ea | |||
| 06e075553a | |||
| 833368c8d7 | |||
| f70bab30d0 | |||
| f230af89eb | |||
| bbc27209ab | |||
| b7638c648a | |||
| c8926cf887 | |||
| b8dd3ee258 | |||
| dc965687c2 | |||
| ebc66bbf64 | |||
| d04db0ecb5 | |||
| fc72b23b11 | |||
| a681b4d63b | |||
| c452ac24e0 | |||
| ae889bff7d | |||
| 99dc86dc27 | |||
| 8eefcb8290 | |||
| ad0f9a54d0 | |||
| 1531b77b5c | |||
| 9437fdba60 | |||
| a7551ef162 | |||
| c20204a6ea | |||
| 685de842ea | |||
| 7d13967708 | |||
| 109697a746 | |||
| dde28f4aff | |||
| 2f6ed3770f | |||
| e3d1cfb838 | |||
| 1821ac0d7b | |||
| 4eb8289e55 | |||
| 66e805542c | |||
| f91b127a2c | |||
| b2077678e2 | |||
| 5427198185 | |||
| 2bdba14cd6 | |||
| 95d8c4e4d6 | |||
| 6ab7745151 | |||
| 8b062a6c1d | |||
| cd48e7a402 | |||
| 22830b0cea | |||
| fd36034f6d | |||
| edf9fbd9c1 | |||
| e8e989bb63 | |||
| 69401d11c8 | |||
| 759caef45d | |||
| 9514861b32 | |||
| aa644aa9cf | |||
| 94820b1d9c | |||
| 4db8793d5c | |||
| 7c6e895ae4 | |||
| b1b67528bf | |||
| dd54a33159 | |||
| 92c4f91e5a | |||
| 838b19e996 | |||
| 3808277025 | |||
| f64863f2bc | |||
| 2c199c0e93 | |||
| 4924ef316f | |||
| 64cb17e91f | |||
| 1fd325823b | |||
| 1590ce5f18 | |||
| 3548c29f97 | |||
| 0fa831fa42 | |||
| a2f64a98c3 | |||
| 872ca17432 | |||
| 224c165d72 | |||
| bf7d2514f2 | |||
| 4e37bc5ab9 | |||
| 125da84f4e | |||
| 36ceb4c7fe | |||
| 88a3831975 | |||
| 63361964ca | |||
| 40b54b27f4 | |||
| a7eca4b9a7 | |||
| d152412e99 | |||
| 3ba6c6b6e4 | |||
| bffbf47c2f | |||
| f4e81da533 | |||
| 4b7f5459be | |||
| c68b0e9d7e | |||
| 32ec65116b | |||
| da8d26fcd9 | |||
| d33954e494 | |||
| 1b306d6493 | |||
| c881143e1b | |||
| 141700fcb3 | |||
| 7357b5bfec | |||
| 99cabd0007 | |||
| cf77e12cc3 | |||
| 3f2cbbb34a | |||
| 650ecf12c6 | |||
| d7cc009d07 | |||
| a872cf3611 | |||
| 1f9713312b | |||
| 159e555d7c | |||
| 981f4f9c9a | |||
| ddd5ce1392 | |||
| 7a75b31b56 | |||
| 24ac545f55 | |||
| d5da8ae701 | |||
| 53a04d064d | |||
| c0871a3b9e | |||
| d917dd8b2c | |||
| 6fc8084f2d | |||
| 41d6fe8ff6 | |||
| 73838312cd | |||
| eb2dd4c839 |
@ -39,8 +39,8 @@ steps:
|
||||
- vrobbler collectstatic --noinput
|
||||
- immortalctl restart celery && immortalctl restart vrobbler
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
ref:
|
||||
- refs/tags/*
|
||||
- name: build success notification
|
||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||
when:
|
||||
|
||||
1080
PROJECT.org
Normal file
1080
PROJECT.org
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
5349
poetry.lock
generated
5349
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9,<3.12"
|
||||
python = ">=3.11,<3.14"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
@ -16,8 +16,8 @@ httpx = "<=0.27.2"
|
||||
djangorestframework = "^3.13.1"
|
||||
Markdown = "^3.3.6"
|
||||
django-filter = "^21.1"
|
||||
Pillow = "^9.0.1"
|
||||
psycopg2 = "^2.9.3"
|
||||
Pillow = "^10.0.0"
|
||||
psycopg2 = "2.9.10"
|
||||
dj-database-url = "^0.5.0"
|
||||
django-mathfilters = "^1.0.0"
|
||||
django-allauth = "^0.50.0"
|
||||
@ -28,7 +28,7 @@ django-markdownify = "^0.9.1"
|
||||
gunicorn = "^20.1.0"
|
||||
django-simple-history = "^3.1.1"
|
||||
musicbrainzngs = "^0.7.1"
|
||||
cinemagoer = "^2022.12.27"
|
||||
cinemagoerng = {git = "https://github.com/cinemagoer/cinemagoerng"}
|
||||
pysportsdb = "^0.1.0"
|
||||
pytz = "^2022.7.1"
|
||||
django-redis = "^5.2.0"
|
||||
@ -41,11 +41,11 @@ beautifulsoup4 = "^4.11.2"
|
||||
django-storages = "^1.13.2"
|
||||
stream-sqlite = "^0.0.41"
|
||||
ipython = "^8.14.0"
|
||||
pendulum = "^2.1.2"
|
||||
pendulum = "^3"
|
||||
trafilatura = "^1.6.3"
|
||||
django-imagekit = "^5.0.0"
|
||||
thefuzz = "^0.22.1"
|
||||
dataclass-wizard = "0.22.0"
|
||||
dataclass-wizard = "^0.35.0"
|
||||
webdavclient3 = "^3.14.6"
|
||||
boto3 = "^1.35.37"
|
||||
urllib3 = "<2"
|
||||
@ -54,6 +54,11 @@ meta-yt = "^0.1.9"
|
||||
berserk = "^0.13.2"
|
||||
poetry-bumpversion = "^0.3.3"
|
||||
orgparse = "^0.4.20250520"
|
||||
tmdbv3api = "^1.9.0"
|
||||
themoviedb = "^1.0.2"
|
||||
feedparser = "^6.0.12"
|
||||
titlecase = "^2.4.1"
|
||||
bgg-api = "^1.1.13"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
from boardgames.bgg import (
|
||||
take_first,
|
||||
lookup_boardgame_id_from_bgg,
|
||||
@ -5,12 +6,14 @@ from boardgames.bgg import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_take_first():
|
||||
assert take_first([]) == ""
|
||||
|
||||
assert take_first(["a", "b"]) == "a"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_lookup_boardgame_id_from_bgg():
|
||||
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
||||
assert bgg_id == "15"
|
||||
@ -19,6 +22,7 @@ def test_lookup_boardgame_id_from_bgg():
|
||||
assert bgg_id == None
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_lookup_boardgame_from_bgg():
|
||||
bgg_result = lookup_boardgame_from_bgg(15)
|
||||
assert bgg_result.get("bggeek_id") == 15
|
||||
|
||||
@ -7,23 +7,24 @@ from rest_framework.authtoken.models import Token
|
||||
from boardgames.models import BoardGame
|
||||
from music.models import Track, Artist
|
||||
from scrobbles.models import Scrobble
|
||||
from people.models import Person
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boardgame_scrobble():
|
||||
user = User.objects.create(
|
||||
email="test@exmaple.com", first_name="Test", last_name="User"
|
||||
)
|
||||
first = Person.objects.create(name="First Player")
|
||||
second = Person.objects.create(name="Second Player")
|
||||
return Scrobble.objects.create(
|
||||
board_game=BoardGame.objects.create(title="Test Board Game"),
|
||||
media_type="BoardGame",
|
||||
played_to_completion=True,
|
||||
log={
|
||||
"players": [
|
||||
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"}
|
||||
]
|
||||
{"person_id": first.id, "win": True, "score": 30, "color": "Blue"},
|
||||
{"person_id": second.id, "win": False, "score": 28, "color": "Red"}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@ -33,7 +34,7 @@ def test_track():
|
||||
Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=Artist.objects.create(name="Carly Rae Jepsen"),
|
||||
run_time_seconds=60,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
@ -114,6 +115,12 @@ def mopidy_podcast_request_data():
|
||||
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||
).request_json
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_podcast_https_request_data():
|
||||
mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6"
|
||||
return MopidyRequest(
|
||||
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||
).request_json
|
||||
|
||||
class JellyfinTrackRequest:
|
||||
name = "Emotion"
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
import pytest
|
||||
|
||||
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||
#from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to get local tests running working again")
|
||||
@pytest.mark.django_db
|
||||
def test_boardgame_log_data(boardgame_scrobble):
|
||||
assert not boardgame_scrobble.geo_location
|
||||
assert boardgame_scrobble.logdata == BoardGameLogData(
|
||||
players=[
|
||||
BoardGameScoreLogData(
|
||||
user_id=1,
|
||||
name_str="",
|
||||
person_id=1,
|
||||
bgg_username="",
|
||||
color="Blue",
|
||||
character=None,
|
||||
@ -18,10 +17,24 @@ def test_boardgame_log_data(boardgame_scrobble):
|
||||
score=30,
|
||||
win=True,
|
||||
new=None,
|
||||
)
|
||||
rank=None,
|
||||
seat_order=None,
|
||||
role=None
|
||||
),
|
||||
BoardGameScoreLogData(
|
||||
person_id=2,
|
||||
bgg_username="",
|
||||
color="Red",
|
||||
character=None,
|
||||
team=None,
|
||||
score=28,
|
||||
win=False,
|
||||
new=None,
|
||||
rank=None,
|
||||
seat_order=None,
|
||||
role=None
|
||||
),
|
||||
],
|
||||
location=None,
|
||||
geo_location_id=None,
|
||||
difficulty=None,
|
||||
solo=None,
|
||||
two_handed=None,
|
||||
|
||||
@ -30,6 +30,7 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@patch(
|
||||
@ -105,6 +106,7 @@ def test_scrobble_mopidy_podcast(
|
||||
assert scrobble.media_obj.title == "Up First"
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@patch(
|
||||
@ -149,6 +151,7 @@ def test_scrobble_jellyfin_track(
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@patch(
|
||||
@ -199,6 +202,7 @@ def test_scrobble_jellyfin_track_update(
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@patch(
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
from videos.sources.imdb import lookup_video_from_imdb
|
||||
|
||||
|
||||
def test_lookup_imdb():
|
||||
def test_lookup_imdb_without_tt():
|
||||
metadata = lookup_video_from_imdb("8946378")
|
||||
print(metadata.__dict__)
|
||||
assert not metadata.imdb_id
|
||||
|
||||
def test_lookup_imdb_with_tt():
|
||||
metadata = lookup_video_from_imdb("tt8946378")
|
||||
assert metadata.title == "Knives Out"
|
||||
|
||||
484
todos.org
484
todos.org
@ -1,484 +0,0 @@
|
||||
#+title: TODOs
|
||||
|
||||
A fun way to keep track of things in the project to fix or improve.
|
||||
|
||||
* Version 0.16.0
|
||||
** TODO [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
|
||||
Effectively, any track that comes in without a MusicBrainz ID does some funky
|
||||
lookup where it doesn't find a track without an MB id and the track title /
|
||||
artist combination and creates a new track everytime. This has to be cleaend up
|
||||
by condensing the duplicated tracks into the original proper track.
|
||||
|
||||
But it opens a bigger quesiton about how much MB id should the drive the app
|
||||
lookup. If it can't be depended on to exist from all sources, it really can't be
|
||||
canonical. Instead, the combination of track title / artist is really the best
|
||||
we can do. Last.fm also has this problem, where it doesn't know about albums and
|
||||
definitely does not know or care about MB ids.
|
||||
|
||||
** DONE Add a user profile page with ability to change settings :profiles:improvement:
|
||||
- Note taken on [2025-04-04 Fri 10:51]
|
||||
[[orgit-rev:~/src/code.unbl.ink/secstate/vrobbler/::93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44][~/src/code.unbl.ink/secstate/vrobbler/ (magit-rev 93c16d8)]]
|
||||
|
||||
https://code.unbl.ink/secstate/vrobbler/commit/93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44
|
||||
|
||||
** DONE What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
|
||||
- Note taken on [2025-04-04 Fri 10:46]
|
||||
|
||||
Nothing. Over the last few months I built out a youtube model in videos and
|
||||
use a bookmarklet scrobbling pattern. Now web-scrobbler is just disabled for
|
||||
Youtube.
|
||||
|
||||
May want to revisit this at some point and only scrobble tracks from Youtube,
|
||||
because many people use YT for music listening.
|
||||
|
||||
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
|
||||
CLOSED: [2023-04-06 Thu 14:09]
|
||||
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
|
||||
CLOSED: [2023-04-02 Sun 23:58]
|
||||
|
||||
Essentially, we currently have the timestamp as when the content began
|
||||
scrobbling and then calculate the finish time from the length of the content.
|
||||
This works pretty well because we know how long most things are.
|
||||
|
||||
But in some cases, sports events or long podcasts, we may start mid-way through
|
||||
an event or finish halfway through but still want to mark it as done. In these
|
||||
cases, knowing the finish time could be useful, especially when interfacing with
|
||||
other scrobblers which may have different definitions of when a scrobble
|
||||
finishes or started.
|
||||
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
|
||||
CLOSED: [2023-03-27 Mon 20:18]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
|
||||
:END:
|
||||
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
|
||||
CLOSED: [2023-03-26 Sun 13:52]
|
||||
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
|
||||
CLOSED: [2023-03-26 Sun 13:51]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
|
||||
:END:
|
||||
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
|
||||
CLOSED: [2023-03-24 Fri 14:46]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
|
||||
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
|
||||
:END:
|
||||
** DONE Fix vrobbler settings not using booleans :settings:bug:
|
||||
CLOSED: [2023-03-24 Fri 10:45]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
|
||||
:END:
|
||||
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
|
||||
CLOSED: [2023-03-24 Fri 00:31]
|
||||
The live view will be blank every Monday, no reason to tie it to a day of the
|
||||
week. It should be "the last 7 days"
|
||||
** DONE [#B] Implement a detail view for TV shows :improvement:views:
|
||||
CLOSED: [2023-03-22 Wed 17:05]
|
||||
** DONE [#B] Implement a detail view for Movies :improvement:views:
|
||||
CLOSED: [2023-03-22 Wed 17:05]
|
||||
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
|
||||
CLOSED: [2023-03-22 Wed 17:04]
|
||||
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
|
||||
CLOSED: [2023-03-22 Wed 17:01]
|
||||
** DONE Add live chart view like Maloja :improvement:views:
|
||||
CLOSED: [2023-03-07 Tue 11:13]
|
||||
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
|
||||
CLOSED: [2023-03-22 Wed 17:06]
|
||||
|
||||
An example:
|
||||
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
|
||||
|
||||
This is actually going to be moot because we can import from LastFM, and
|
||||
web-scrobbler integrates well with LastFM. The only thing to think through here
|
||||
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
|
||||
from Youtube (all videos get pushed, sigh).
|
||||
|
||||
** DONE Add Amazon scraper to look up books when OL fails :books:improvement:
|
||||
This turned out to be a non-starter ... Amazon is aggressive at disallowing
|
||||
scraping quality. And all the OSS tools out there are stuck in an arms race
|
||||
trying to keep them from breaking.
|
||||
|
||||
That said, Google Books actually has a decent API (for now), and I've built this
|
||||
out using that.
|
||||
|
||||
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
|
||||
This was fixed a while ago, but there's a new manifested bug. Going to create a
|
||||
separate bug tracking ticket for that.
|
||||
* Version 0.11.4
|
||||
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
|
||||
CLOSED: [2023-03-07 Tue 11:11]
|
||||
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
|
||||
CLOSED: [2023-03-07 Tue 11:11]
|
||||
|
||||
** DONE [#A] Fix fetching artwork without release group :bug:
|
||||
CLOSED: [2023-01-29 Sun 14:27]
|
||||
|
||||
When we get artwork from Musicbrianz, and it's not found, we should check for
|
||||
release groups as well. This will stop issues with missing artwork because of
|
||||
obscure MB release matches.
|
||||
|
||||
** DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
|
||||
CLOSED: [2023-01-30 Mon 18:31]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
|
||||
:END:
|
||||
|
||||
If we play music from Jellyfin and the track reaches 90% completion, the
|
||||
scrobbling goes crazy and starts creating new scrobbles with every update.
|
||||
|
||||
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
|
||||
as complete for the following conditions:
|
||||
|
||||
- Play stopped and percent played beyond 90%
|
||||
- Play completely finished
|
||||
|
||||
But if we keep listening beyond 90, we should basically ignore updates (or just
|
||||
update the existing scrobble)
|
||||
** DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
|
||||
CLOSED: [2023-02-03 Fri 16:52]
|
||||
|
||||
An example of the format:
|
||||
#+begin_src csv
|
||||
,
|
||||
#AUDIOSCROBBLER/1.1
|
||||
#TZ/UNKNOWN
|
||||
#CLIENT/Rockbox sansaclipplus $Revision$
|
||||
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
|
||||
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
|
||||
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
||||
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
||||
311 311 Jackolantern’s Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
|
||||
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
|
||||
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
|
||||
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
|
||||
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
|
||||
,
|
||||
#+end_src
|
||||
** DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
|
||||
CLOSED: [2023-02-17 Fri 00:10]
|
||||
|
||||
This would allow a few nice flows. One, you'd be able to record the play of an
|
||||
entire album by just dropping the muscibrainz_id in. This could be helpful for
|
||||
offline listening. It would also mean bad metadata from mopidy would not break
|
||||
scrobbling.
|
||||
** DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
|
||||
CLOSED: [2023-02-17 Fri 00:11]
|
||||
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
|
||||
CLOSED: [2023-03-07 Tue 11:09]
|
||||
|
||||
Given a UUID from musicbrainz, we should be able to scrobble an album or
|
||||
individual track.
|
||||
|
||||
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
|
||||
CLOSED: [2023-03-07 Tue 11:10]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
|
||||
:END:
|
||||
|
||||
Maloja does this cool thing where artists and tracks get recorded as the top
|
||||
track of a given week, month or year. They get gold, silver or bronze stars for
|
||||
their place in the time period.
|
||||
|
||||
I could see this being implemented as a separate Chart table which gets
|
||||
populated at the end of a time period and has a start and end date that defines
|
||||
a period, along with a one, two, three instance.
|
||||
|
||||
Of course, it could also be a data model without a table, where it runs some fun
|
||||
calculations, stores it's values in Redis as a long-term lookup table and just
|
||||
has to re-populate when the server restarts.
|
||||
* Backlog
|
||||
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
|
||||
*** Example payloads from mopidy-webhooks
|
||||
**** Podcast playback ended
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_ended",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 13,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
}
|
||||
},
|
||||
"time_position": 3290
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Podcast playback state changes
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "playback_state_changed",
|
||||
"data": {
|
||||
"old_state": "paused",
|
||||
"new_state": "playing"
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "playback_state_changed",
|
||||
"data": {
|
||||
"old_state": "stopped",
|
||||
"new_state": "playing"
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Podcast playback started
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_started",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 13,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Podcast playback paused
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"state": "paused",
|
||||
"current_track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
},
|
||||
"time_position": 2350
|
||||
}
|
||||
}
|
||||
|
||||
#+end_src
|
||||
**** Track playback started
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_started",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 14,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Track playback in progress
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"state": "playing",
|
||||
"current_track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
},
|
||||
"time_position": 17031
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
**** Track event playback paused
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_paused",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 14,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
}
|
||||
},
|
||||
"time_position": 67578
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
** TODO Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug:
|
||||
0
vrobbler/apps/beers/api/__init__.py
Normal file
0
vrobbler/apps/beers/api/__init__.py
Normal file
18
vrobbler/apps/beers/api/serializers.py
Normal file
18
vrobbler/apps/beers/api/serializers.py
Normal file
@ -0,0 +1,18 @@
|
||||
from rest_framework import serializers
|
||||
from beers.models import Beer, BeerProducer, BeerStyle
|
||||
|
||||
|
||||
class BeerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Beer
|
||||
fields = "__all__"
|
||||
|
||||
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerProducer
|
||||
fields = "__all__"
|
||||
|
||||
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerStyle
|
||||
fields = "__all__"
|
||||
19
vrobbler/apps/beers/api/views.py
Normal file
19
vrobbler/apps/beers/api/views.py
Normal file
@ -0,0 +1,19 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from beers.api import serializers
|
||||
from beers import models
|
||||
|
||||
|
||||
class BeerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Beer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
class BeerProducerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerProducer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerProducerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
class BeerStyleViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerStyle.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerStyleSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('beers', '0005_alter_beer_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='beer',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='beer',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='beer',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,18 +1,25 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from beers.untappd import get_beer_from_untappd_id, get_rating_from_soup
|
||||
from beers.untappd import get_beer_from_untappd_id
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BeerLogData
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeerLogData(BaseLogData):
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class BeerStyle(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from boardgames.models import BoardGame, BoardGamePublisher
|
||||
from boardgames.models import (
|
||||
BoardGame,
|
||||
BoardGameLocation,
|
||||
BoardGamePublisher,
|
||||
BoardGameDesigner,
|
||||
)
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
@ -15,13 +20,34 @@ class BoardGamePublisherAdmin(admin.ModelAdmin):
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGameDesigner)
|
||||
class BoardGameDesignerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"uuid",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGameLocation)
|
||||
class BoardGameLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"uuid",
|
||||
"geo_location",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGame)
|
||||
class GameAdmin(admin.ModelAdmin):
|
||||
class BoardGameAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"bggeek_id",
|
||||
"title",
|
||||
"published_date",
|
||||
"published_year",
|
||||
)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
|
||||
22
vrobbler/apps/boardgames/api/serializers.py
Normal file
22
vrobbler/apps/boardgames/api/serializers.py
Normal file
@ -0,0 +1,22 @@
|
||||
from boardgames import models
|
||||
from rest_framework import serializers
|
||||
|
||||
class BoardGameDesignerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGameDesigner
|
||||
fields = "__all__"
|
||||
|
||||
class BoardGamePublisherSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGamePublisher
|
||||
fields = "__all__"
|
||||
|
||||
class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGameLocation
|
||||
fields = "__all__"
|
||||
|
||||
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGame
|
||||
fields = "__all__"
|
||||
28
vrobbler/apps/boardgames/api/views.py
Normal file
28
vrobbler/apps/boardgames/api/views.py
Normal file
@ -0,0 +1,28 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from boardgames.api import serializers
|
||||
from boardgames import models
|
||||
|
||||
|
||||
class BoardGameDesignerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGameDesigner.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameDesignerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGamePublisherViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGamePublisher.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGamePublisherSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameLocationViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGameLocation.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameLocationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGame.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
|
||||
User = get_user_model()
|
||||
if TYPE_CHECKING:
|
||||
@ -17,6 +18,8 @@ SEARCH_ID_URL = (
|
||||
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||
)
|
||||
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
|
||||
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
|
||||
BASE_HEADERS = {"User-Agent": "Vrobbler 31.0", "Authorization": f"Bearer {BGG_ACCESS_TOKEN}"}
|
||||
|
||||
|
||||
def take_first(thing: Optional[list]) -> str:
|
||||
@ -37,10 +40,9 @@ def take_first(thing: Optional[list]) -> str:
|
||||
|
||||
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||
soup = None
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
game_id = None
|
||||
url = SEARCH_ID_URL.format(query=title)
|
||||
r = requests.get(url, headers=headers)
|
||||
r = requests.get(url, headers=BASE_HEADERS)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
@ -57,7 +59,6 @@ def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
soup = None
|
||||
game_dict = {}
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
|
||||
title = ""
|
||||
bgg_id = None
|
||||
@ -73,7 +74,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
bgg_id = lookup_boardgame_id_from_bgg(title)
|
||||
|
||||
url = GAME_ID_URL.format(id=bgg_id)
|
||||
r = requests.get(url, headers=headers)
|
||||
r = requests.get(url, headers=BASE_HEADERS)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
@ -100,8 +101,8 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
|
||||
|
||||
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
bgg_username = user.profile.bgg_username
|
||||
bgg_password = user.profile.bgg_password
|
||||
bgg_username = "secstate" # user.profile.bgg_username
|
||||
bgg_password = "yYFCKnfo8AK89lc68q0S"
|
||||
|
||||
if not bgg_username or bgg_password:
|
||||
return
|
||||
@ -109,7 +110,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
login_payload = {
|
||||
"credentials": {"username": bgg_username, "password": bgg_password}
|
||||
}
|
||||
headers = {"content-type": "application/json"}
|
||||
headers = BASE_HEADERS
|
||||
headers["content-type"] = "application/json"
|
||||
|
||||
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
|
||||
|
||||
@ -119,24 +121,20 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
data=json.dumps(login_payload),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
players = []
|
||||
if scrobble.metadata:
|
||||
for player in scrobble.metadata.players:
|
||||
if player["user_id"]:
|
||||
player_user = User.objects.filter(
|
||||
id=player["user_id"]
|
||||
).first()
|
||||
if player_user:
|
||||
if player_user.bgg_username:
|
||||
player["username"] = player_user.bgg_username
|
||||
else:
|
||||
player["name"] = player_user.username
|
||||
player["win"] = player.get("win")
|
||||
player["color"] = player.get("color")
|
||||
player["new"] = player.get("new")
|
||||
player["score"] = player.get("score")
|
||||
players.append(player)
|
||||
if scrobble.log:
|
||||
for player in scrobble.log.get("players"):
|
||||
player_person = Person.objects.filter(
|
||||
id=player.get("person_id")
|
||||
).first()
|
||||
if player_person.get("bgg_username"):
|
||||
player["username"] = player_person.get("bgg_username")
|
||||
player["name"] = player_person.get("name")
|
||||
player["win"] = player.get("win")
|
||||
# player["role"] = player.get("role")
|
||||
player["new"] = player.get("new")
|
||||
player["score"] = player.get("score")
|
||||
players.append(player)
|
||||
|
||||
play_payload = {
|
||||
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
|
||||
@ -150,3 +148,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
"objecttype": "thing",
|
||||
"ajax": 1,
|
||||
}
|
||||
r = s.post(
|
||||
"https://boardgamegeek.com/geekplay.php",
|
||||
data=json.dumps(play_payload),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-03 01:57
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("locations", "0007_alter_geolocation_run_time_seconds"),
|
||||
("boardgames", "0007_alter_boardgame_run_time_seconds"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BoardGameDesigner",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("bgg_id", models.IntegerField(blank=True, null=True)),
|
||||
("bio", models.TextField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="boardgamepublisher",
|
||||
old_name="igdb_id",
|
||||
new_name="bgg_id",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="bgstats_id",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="cooperative",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="expansion_for_boardgame",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="boardgames.boardgame",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="highest_wins",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="max_play_time",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="min_play_time",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="no_points",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="uses_teams",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BoardGameLocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("bgstats_id", models.UUIDField(blank=True, null=True)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"geo_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="locations.geolocation",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="designers",
|
||||
field=models.ManyToManyField(
|
||||
related_name="board_games", to="boardgames.boardgamedesigner"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-03 02:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0008_boardgamedesigner_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="cooperative",
|
||||
field=models.BooleanField(blank=True, default=False, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="highest_wins",
|
||||
field=models.BooleanField(blank=True, default=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="no_points",
|
||||
field=models.BooleanField(blank=True, default=False, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="uses_teams",
|
||||
field=models.BooleanField(blank=True, default=False, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-03 04:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0009_alter_boardgame_cooperative_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="published_year",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0010_boardgame_published_year'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='boardgame',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='boardgame',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-11-03 04:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0011_remove_boardgame_run_time_seconds_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='bgg_rank',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-11-03 04:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0012_boardgame_bgg_rank'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='publishers',
|
||||
field=models.ManyToManyField(related_name='board_games', to='boardgames.boardgamepublisher'),
|
||||
),
|
||||
]
|
||||
@ -1,10 +1,13 @@
|
||||
from functools import cached_property
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from django import forms
|
||||
import requests
|
||||
from boardgames.bgg import lookup_boardgame_from_bgg
|
||||
from boardgames.sources.bgg import lookup_boardgame_from_bgg
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
@ -12,18 +15,120 @@ from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BoardGameLogData
|
||||
from locations.models import GeoLocation
|
||||
from people.models import Person
|
||||
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoardGameScoreLogData(BaseLogData):
|
||||
person_id: Optional[int] = None
|
||||
bgg_username: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
character: Optional[str] = None
|
||||
team: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
win: Optional[bool] = None
|
||||
new: Optional[bool] = None
|
||||
rank: Optional[int] = None
|
||||
seat_order: Optional[int] = None
|
||||
role: Optional[str] = None
|
||||
rank: Optional[int] = None
|
||||
seat_order: Optional[int] = None
|
||||
role: Optional[str] = None
|
||||
lichess_username: Optional[str] = None
|
||||
|
||||
@property
|
||||
def person(self) -> Optional[Person]:
|
||||
return Person.objects.filter(id=self.person_id).first()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
name = ""
|
||||
if self.person:
|
||||
name = self.person.name
|
||||
return name
|
||||
|
||||
def __str__(self) -> str:
|
||||
out = self.name
|
||||
if self.score:
|
||||
out += f" {self.score}"
|
||||
if self.color:
|
||||
out += f" ({self.color})"
|
||||
if self.win:
|
||||
out += f" [W]"
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
players: Optional[list[BoardGameScoreLogData]] = None
|
||||
location_id: Optional[int] = None
|
||||
difficulty: Optional[int] = None
|
||||
solo: Optional[bool] = None
|
||||
two_handed: Optional[bool] = None
|
||||
expansion_ids: Optional[int] = None
|
||||
moves: Optional[list] = None
|
||||
rated: Optional[str] = None
|
||||
speed: Optional[str] = None
|
||||
variant: Optional[str] = None
|
||||
lichess_id: Optional[int] = None
|
||||
board: Optional[str] = None
|
||||
rounds: Optional[int] = None
|
||||
details: Optional[str] = None
|
||||
|
||||
_excluded_fields = {
|
||||
"lichess_id",
|
||||
"speed",
|
||||
"rated",
|
||||
"moves",
|
||||
"variant",
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def location(self):
|
||||
if not self.location_id:
|
||||
return
|
||||
return BoardGameLocation.objects.filter(id=self.location_id).first()
|
||||
|
||||
@cached_property
|
||||
def player_log(self) -> str:
|
||||
if self.players:
|
||||
return ", ".join(
|
||||
[
|
||||
BoardGameScoreLogData(**player).__str__()
|
||||
for player in self.players
|
||||
]
|
||||
)
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
)
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
|
||||
class BoardGamePublisher(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
|
||||
igdb_id = models.IntegerField(**BNULL)
|
||||
bgg_id = models.IntegerField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -34,6 +139,39 @@ class BoardGamePublisher(TimeStampedModel):
|
||||
)
|
||||
|
||||
|
||||
class BoardGameDesigner(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
bgg_id = models.IntegerField(**BNULL)
|
||||
bio = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:designer_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
|
||||
class BoardGameLocation(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
bgstats_id = models.UUIDField(**BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
geo_location = models.ForeignKey(
|
||||
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:location_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
|
||||
class BoardGame(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(
|
||||
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
||||
@ -53,6 +191,14 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher = models.ForeignKey(
|
||||
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
publishers = models.ManyToManyField(
|
||||
BoardGamePublisher,
|
||||
related_name="board_games",
|
||||
)
|
||||
designers = models.ManyToManyField(
|
||||
BoardGameDesigner,
|
||||
related_name="board_games",
|
||||
)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
|
||||
@ -82,11 +228,23 @@ class BoardGame(ScrobblableMixin):
|
||||
options={"quality": 75},
|
||||
)
|
||||
rating = models.FloatField(**BNULL)
|
||||
bgg_rank = models.IntegerField(**BNULL)
|
||||
max_players = models.PositiveSmallIntegerField(**BNULL)
|
||||
min_players = models.PositiveSmallIntegerField(**BNULL)
|
||||
published_date = models.DateField(**BNULL)
|
||||
published_year = models.IntegerField(**BNULL)
|
||||
recommended_age = models.PositiveSmallIntegerField(**BNULL)
|
||||
bggeek_id = models.CharField(max_length=255, **BNULL)
|
||||
bgstats_id = models.UUIDField(**BNULL)
|
||||
uses_teams = models.BooleanField(default=False, **BNULL)
|
||||
cooperative = models.BooleanField(default=False, **BNULL)
|
||||
highest_wins = models.BooleanField(default=True, **BNULL)
|
||||
no_points = models.BooleanField(default=False, **BNULL)
|
||||
min_play_time = models.IntegerField(**BNULL)
|
||||
max_play_time = models.IntegerField(**BNULL)
|
||||
expansion_for_boardgame = models.ForeignKey(
|
||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@ -128,7 +286,7 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher_name = data.pop("publisher_name")
|
||||
|
||||
if year:
|
||||
data["published_date"] = datetime(int(year), 1, 1)
|
||||
data["published_year"] = int(year)
|
||||
|
||||
if not data["min_players"]:
|
||||
data.pop("min_players")
|
||||
@ -148,29 +306,58 @@ class BoardGame(ScrobblableMixin):
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not self.cover:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
logger.debug(r.status_code)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
logger.debug("Loaded cover image from BGGeek")
|
||||
self.save_image_from_url(cover_url)
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, lookup_id: str, data: Optional[dict] = {}
|
||||
) -> Optional["BoardGame"]:
|
||||
cls, lookup_id: str, data: dict[str, Any] = {}
|
||||
) -> "BoardGame":
|
||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||
boardgame = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
|
||||
if not data or not boardgame:
|
||||
data = lookup_boardgame_from_bgg(lookup_id)
|
||||
if game:
|
||||
logger.info("Board game exists in database.", extra={"lookup_id": lookup_id, "data": data})
|
||||
return game
|
||||
|
||||
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)
|
||||
bgg_data = lookup_boardgame_from_bgg(data.get("name"))
|
||||
|
||||
return boardgame
|
||||
mechanics = bgg_data.pop("mechanics", [])
|
||||
designers = bgg_data.pop("designers", [])
|
||||
categories = bgg_data.pop("categories", [])
|
||||
publishers = bgg_data.pop("publishers", [])
|
||||
cover_url = bgg_data.pop("cover_url")
|
||||
|
||||
game = cls.objects.create(
|
||||
**bgg_data
|
||||
)
|
||||
|
||||
game.save_image_from_url(cover_url)
|
||||
game.cooperative = data.get("cooperative", False)
|
||||
game.highest_wins = data.get("highestWins", True)
|
||||
game.no_points = data.get("noPoints", False)
|
||||
game.uses_teams = data.get("useTeams", False)
|
||||
game.bgstats_id = data.get("uuid", None)
|
||||
game.save()
|
||||
|
||||
if designers:
|
||||
for designer_name in designers:
|
||||
designer, created = BoardGameDesigner.objects.get_or_create(
|
||||
name=designer_name
|
||||
)
|
||||
game.designers.add(designer.id)
|
||||
|
||||
if publishers:
|
||||
for name in publishers:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(
|
||||
name=name
|
||||
)
|
||||
game.publishers.add(publisher)
|
||||
|
||||
return game
|
||||
|
||||
29
vrobbler/apps/boardgames/sources/bgg.py
Normal file
29
vrobbler/apps/boardgames/sources/bgg.py
Normal file
@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
from boardgamegeek import BGGClient
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
def lookup_boardgame_from_bgg(title: str) -> dict[str, Any]:
|
||||
game_dict = {"title": title}
|
||||
|
||||
bgg = BGGClient(access_token=settings.BGG_ACCESS_TOKEN)
|
||||
|
||||
game = bgg.game(title)
|
||||
|
||||
if game:
|
||||
game_dict["description"] = game.description
|
||||
game_dict["published_year"] = game.yearpublished
|
||||
game_dict["cover_url"] = game.image
|
||||
game_dict["min_players"] = game.minplayers
|
||||
game_dict["max_players"] = game.maxplayers
|
||||
game_dict["recommended_age"] = game.minage
|
||||
game_dict["rating"] = game.rating_average
|
||||
game_dict["bgg_rank"] = game.bgg_rank
|
||||
game_dict["base_run_time_seconds"] = int(game.playingtime) * 60 if game.playingtime else None
|
||||
|
||||
game_dict["mechanics"] = game.mechanics
|
||||
game_dict["categories"] = game.categories
|
||||
game_dict["designers"] = game.designers
|
||||
game_dict["publishers"] = game.publishers
|
||||
|
||||
return game_dict
|
||||
@ -3,7 +3,7 @@ from boardgames.models import BoardGame
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.notifications import NtfyNotification
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -18,9 +18,9 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
for game_dict in games:
|
||||
chess, created = BoardGame.objects.get_or_create(title="Chess")
|
||||
if created:
|
||||
chess.run_time_seconds = 1800
|
||||
chess.base_run_time_seconds = 1800
|
||||
chess.bggeek_id = 171
|
||||
chess.save(update_fields=["run_time_seconds", "bggeek_id"])
|
||||
chess.save(update_fields=["base_run_time_seconds", "bggeek_id"])
|
||||
scrobble = Scrobble.objects.filter(
|
||||
user_id=user.id,
|
||||
timestamp=game_dict.get("createdAt"),
|
||||
@ -124,5 +124,5 @@ def import_chess_games_for_all_users():
|
||||
if scrobbles_to_create:
|
||||
created = Scrobble.objects.bulk_create(scrobbles_to_create)
|
||||
for scrobble in created:
|
||||
NtfyNotification(scrobble).send()
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return scrobbles_to_create
|
||||
|
||||
@ -21,7 +21,8 @@ class BookAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"subtitle",
|
||||
"author",
|
||||
"issue_or_volume",
|
||||
"isbn_13",
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
@ -32,6 +33,9 @@ class BookAdmin(admin.ModelAdmin):
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
def issue_or_volume(self, obj):
|
||||
return obj.issue_number or obj.volume_number
|
||||
|
||||
|
||||
@admin.register(Paper)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from books.api.serializers import (
|
||||
AuthorSerializer,
|
||||
BookSerializer,
|
||||
)
|
||||
from books.models import Author, Book
|
||||
from books.api import serializers
|
||||
from books import models
|
||||
|
||||
|
||||
class AuthorViewSet(viewsets.ModelViewSet):
|
||||
queryset = Author.objects.all().order_by("-created")
|
||||
serializer_class = AuthorSerializer
|
||||
queryset = models.Author.objects.all().order_by("-created")
|
||||
serializer_class = serializers.AuthorSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BookViewSet(viewsets.ModelViewSet):
|
||||
queryset = Book.objects.all().order_by("-created")
|
||||
serializer_class = BookSerializer
|
||||
queryset = models.Book.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BookSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@ -5,3 +5,5 @@ BOOKS_TITLES_TO_IGNORE = [
|
||||
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
|
||||
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
|
||||
]
|
||||
|
||||
READCOMICSONLINE_URL = "https://readcomicsonline.ru"
|
||||
|
||||
@ -3,14 +3,14 @@ import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from books.constants import BOOKS_TITLES_TO_IGNORE
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import get_user_model
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
from stream_sqlite import stream_sqlite
|
||||
from scrobbles.notifications import NtfyNotification
|
||||
from webdav.client import get_webdav_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -114,7 +114,7 @@ def create_book_from_row(row: list):
|
||||
"raw_row_data": clean_row,
|
||||
}
|
||||
},
|
||||
run_time_seconds=run_time,
|
||||
base_run_time_seconds=run_time,
|
||||
)
|
||||
# TODO Move these to async processes after importing
|
||||
# book.fix_metadata()
|
||||
@ -278,55 +278,19 @@ def build_scrobbles_from_book_map(
|
||||
)
|
||||
continue
|
||||
|
||||
timezone = user.profile.timezone
|
||||
timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(int(first_page.get("start_ts")))
|
||||
)
|
||||
stop_timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(int(last_page.get("end_ts")))
|
||||
)
|
||||
|
||||
timestamp = datetime.fromtimestamp(
|
||||
int(first_page.get("start_ts"))
|
||||
).replace(tzinfo=pytz.timezone(timezone))
|
||||
|
||||
# Add a shim here temporarily to fix imports while we were in France
|
||||
# if date is between 10/15 and 12/15, cast it to Europe/Central
|
||||
if (
|
||||
datetime(2023, 10, 15).replace(
|
||||
tzinfo=pytz.timezone("Europe/Paris")
|
||||
)
|
||||
<= timestamp
|
||||
<= datetime(2023, 12, 15).replace(
|
||||
tzinfo=pytz.timezone("Europe/Paris")
|
||||
)
|
||||
):
|
||||
timezone = "Europe/Paris"
|
||||
if (
|
||||
datetime(2024, 4, 28).replace(
|
||||
tzinfo=pytz.timezone("US/Pacific")
|
||||
)
|
||||
<= timestamp
|
||||
<= datetime(2024, 5, 4).replace(
|
||||
tzinfo=pytz.timezone("US/Pacific")
|
||||
)
|
||||
):
|
||||
timezone = "US/Pacific"
|
||||
if (
|
||||
datetime(2024, 8, 4).replace(
|
||||
tzinfo=pytz.timezone("Canada/Atlantic")
|
||||
)
|
||||
<= timestamp
|
||||
<= datetime(2024, 8, 10).replace(
|
||||
tzinfo=pytz.timezone("Canada/Atlantic")
|
||||
)
|
||||
):
|
||||
timezone = "Canada/Atlantic"
|
||||
|
||||
stop_timestamp = datetime.fromtimestamp(
|
||||
int(last_page.get("end_ts"))
|
||||
).replace(tzinfo=pytz.timezone(timezone))
|
||||
|
||||
if (
|
||||
timestamp.tzinfo._dst.seconds == 0
|
||||
or stop_timestamp.tzinfo._dst.seconds == 0
|
||||
):
|
||||
timestamp = timestamp - timedelta(hours=1)
|
||||
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||
# Adjust for Daylight Saving Time
|
||||
#if timestamp.dst() == timedelta(
|
||||
# 0
|
||||
#) or stop_timestamp.dst() == timedelta(0):
|
||||
# timestamp = timestamp - timedelta(hours=1)
|
||||
# stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||
|
||||
scrobble = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
@ -356,7 +320,7 @@ def build_scrobbles_from_book_map(
|
||||
in_progress=False,
|
||||
played_to_completion=True,
|
||||
long_play_complete=False,
|
||||
timezone=timezone,
|
||||
timezone=timestamp.tzinfo.name,
|
||||
)
|
||||
)
|
||||
# Then start over
|
||||
@ -398,9 +362,9 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
|
||||
new_scrobbles = []
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
tz = pytz.utc
|
||||
tz = ZoneInfo("UTC")
|
||||
if user:
|
||||
tz = user.profile.timezone
|
||||
tz = user.profile.tzinfo
|
||||
|
||||
is_os_file = "https://" not in file_path
|
||||
if is_os_file:
|
||||
@ -443,7 +407,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
if new_scrobbles:
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
if created:
|
||||
NtfyNotification(created[-1]).send()
|
||||
ScrobbleNtfyNotification(created[-1]).send()
|
||||
fix_long_play_stats_for_scrobbles(created)
|
||||
logger.info(
|
||||
f"Created {len(created)} scrobbles",
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-20 18:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0028_delete_page'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='comicvine_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='issue_number',
|
||||
field=models.IntegerField(blank=True, max_length=5, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='original_title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='volume_number',
|
||||
field=models.IntegerField(blank=True, max_length=5, null=True),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/books/migrations/0030_book_readcomics_url.py
Normal file
18
vrobbler/apps/books/migrations/0030_book_readcomics_url.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-22 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0029_book_comicvine_id_book_issue_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='readcomics_url',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-22 17:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0030_book_readcomics_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='next_readcomics_url',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,39 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0031_book_next_readcomics_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='book',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='book',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paper',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paper',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paper',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,19 @@
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
from datetime import timedelta, datetime
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from books.constants import READCOMICSONLINE_URL
|
||||
from books.openlibrary import (
|
||||
lookup_author_from_openlibrary,
|
||||
lookup_book_from_openlibrary,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.semantic import lookup_paper_from_semantic
|
||||
from books.utils import get_comic_issue_url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.base import ContentFile
|
||||
@ -16,28 +22,25 @@ from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
|
||||
from scrobbles.mixins import (
|
||||
LongPlayScrobblableMixin,
|
||||
ObjectWithGenres,
|
||||
ScrobblableConstants,
|
||||
ScrobblableMixin,
|
||||
)
|
||||
from scrobbles.utils import get_scrobbles_for_media
|
||||
from scrobbles.utils import get_scrobbles_for_media, next_url_if_exists
|
||||
from taggit.managers import TaggableManager
|
||||
from thefuzz import fuzz
|
||||
from vrobbler.apps.books.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
|
||||
from vrobbler.apps.books.locg import (
|
||||
lookup_comic_by_locg_slug,
|
||||
lookup_comic_from_locg,
|
||||
lookup_comic_writer_by_locg_slug,
|
||||
)
|
||||
from vrobbler.apps.books.sources.google import lookup_book_from_google
|
||||
from vrobbler.apps.books.sources.semantic import lookup_paper_from_semantic
|
||||
from vrobbler.apps.scrobbles.dataclasses import BookLogData
|
||||
from vrobbler.apps.books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
|
||||
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
|
||||
@ -46,6 +49,34 @@ User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookPageLogData(BaseLogData):
|
||||
page_number: Optional[int] = None
|
||||
end_ts: Optional[int] = None
|
||||
start_ts: Optional[int] = None
|
||||
duration: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookLogData(BaseLogData, LongPlayLogData):
|
||||
koreader_hash: Optional[str] = None
|
||||
page_data: Optional[dict[int, BookPageLogData]] = None
|
||||
pages_read: Optional[int] = None
|
||||
page_start: Optional[int] = None
|
||||
page_end: Optional[int] = None
|
||||
resume_url: Optional[str] = None
|
||||
|
||||
_excluded_fields = {"koreader_hash", "page_data"}
|
||||
|
||||
def avg_seconds_per_page(self):
|
||||
if self.page_data:
|
||||
total_duration = 0
|
||||
for page_num, stats in self.page_data.items():
|
||||
total_duration += stats.get("duration", 0)
|
||||
if total_duration:
|
||||
return int(total_duration / len(self.page_data))
|
||||
|
||||
|
||||
class Author(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
@ -107,6 +138,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
original_title = models.CharField(max_length=255, **BNULL)
|
||||
authors = models.ManyToManyField(Author, blank=True)
|
||||
koreader_data_by_hash = models.JSONField(**BNULL)
|
||||
isbn_13 = models.CharField(max_length=255, **BNULL)
|
||||
@ -117,6 +149,13 @@ class Book(LongPlayScrobblableMixin):
|
||||
publish_date = models.DateField(**BNULL)
|
||||
publisher = models.CharField(max_length=255, **BNULL)
|
||||
first_sentence = models.TextField(**BNULL)
|
||||
# ComicVine
|
||||
comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
issue_number = models.IntegerField(**BNULL)
|
||||
volume_number = models.IntegerField(**BNULL)
|
||||
# OpenLibrary
|
||||
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
||||
cover_small = ImageSpecField(
|
||||
@ -135,7 +174,11 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
if self.issue_number and "Issue" not in str(self.title):
|
||||
return f"{self.title} - Issue {self.issue_number}"
|
||||
if self.volume_number and "Volume" not in str(self.title):
|
||||
return f"{self.title} - Volume {self.volume_number}"
|
||||
return f"{self.title}"
|
||||
|
||||
@property
|
||||
@ -161,44 +204,109 @@ class Book(LongPlayScrobblableMixin):
|
||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@classmethod
|
||||
def get_from_google(cls, title: str, overwrite: bool = False):
|
||||
def get_from_comicvine(cls, title: str, overwrite: bool = False, force_new: bool =False) -> "Book":
|
||||
book, created = cls.objects.get_or_create(title=title)
|
||||
if not created and not overwrite:
|
||||
|
||||
if not created:
|
||||
return book
|
||||
|
||||
book_dict = lookup_book_from_google(title)
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
|
||||
if created or overwrite:
|
||||
author_list = []
|
||||
authors = book_dict.pop("authors")
|
||||
cover_url = book_dict.pop("cover_url")
|
||||
try:
|
||||
genres = book_dict.pop("generes")
|
||||
except:
|
||||
genres = []
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
if author_str:
|
||||
author_dicts = book_dict.pop("author_dicts")
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
if author_dict.get("authorId"):
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
name=author_str
|
||||
semantic_id=author_dict.get("authorId")
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
# TODO enrich author
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
# TODO enrich author?
|
||||
...
|
||||
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
|
||||
if author_list:
|
||||
book.authors.add(*author_list)
|
||||
genres = book_dict.pop("genres", [])
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
return book
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, title: str, url: str = "", enrich: bool = False, commit: bool = True
|
||||
):
|
||||
"""Given a title, get a Book instance.
|
||||
|
||||
If the book is not already in our database, or overwrite is True,
|
||||
this method will enrich the Book with data from Google.
|
||||
|
||||
By default this method will also save the data back to the model. If you'd
|
||||
like to batch create, use commit=False and you'll get an unsaved but enriched
|
||||
instance back which you can then save at your convenience."""
|
||||
# TODO use either a Google Books id identifier or author name like for tracks
|
||||
book, created = cls.objects.get_or_create(original_title=title)
|
||||
if not created:
|
||||
logger.info(
|
||||
"Found exact match for book by title", extra={"title": title}
|
||||
)
|
||||
|
||||
if not enrich:
|
||||
logger.info(
|
||||
"Found book by title, but not enriching",
|
||||
extra={"title": title},
|
||||
)
|
||||
return book
|
||||
|
||||
book_dict = None
|
||||
if READCOMICSONLINE_URL in url:
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(book_dict["readcomics_url"])
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_google(title)
|
||||
|
||||
if not book_dict:
|
||||
logger.warning("No book found in any source, using data as is", extra={"title": title})
|
||||
|
||||
author_list = []
|
||||
authors = book_dict.pop("authors", [])
|
||||
cover_url = book_dict.pop("cover_url", "")
|
||||
genres = book_dict.pop("generes", [])
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
if author_str:
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
name=author_str
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
# TODO enrich author
|
||||
...
|
||||
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
|
||||
if commit:
|
||||
book.save()
|
||||
|
||||
book.save_image_from_url(cover_url)
|
||||
book.genre.add(*genres)
|
||||
book.authors.add(*author_list)
|
||||
|
||||
return book
|
||||
|
||||
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||
if not self.cover or (force_update and url):
|
||||
if url and (not self.cover or force_update):
|
||||
r = requests.get(url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
@ -294,7 +402,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if self.pages:
|
||||
self.run_time_seconds = int(self.pages) * int(
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
|
||||
@ -378,27 +486,6 @@ class Book(LongPlayScrobblableMixin):
|
||||
progress = int((last_scrobble.last_page_read / self.pages) * 100)
|
||||
return progress
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
|
||||
book = cls.objects.filter(openlibrary_id=lookup_id).first()
|
||||
|
||||
if not book:
|
||||
data = lookup_book_from_openlibrary(lookup_id, author)
|
||||
|
||||
if not data:
|
||||
logger.error(
|
||||
f"No book found on openlibrary, or in our database for {lookup_id}"
|
||||
)
|
||||
return book
|
||||
|
||||
book, book_created = cls.objects.get_or_create(
|
||||
isbn_13=data["isbn"]
|
||||
)
|
||||
if book_created:
|
||||
book.fix_metadata(data=data)
|
||||
|
||||
return book
|
||||
|
||||
|
||||
class Paper(LongPlayScrobblableMixin):
|
||||
"""Keeps track of Academic Papers"""
|
||||
|
||||
@ -3,7 +3,6 @@ ComicVine API Information & Documentation:
|
||||
https://comicvine.gamespot.com/api/
|
||||
https://comicvine.gamespot.com/api/documentation
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from django.conf import settings
|
||||
|
||||
@ -200,34 +199,72 @@ class ComicVineClient(object):
|
||||
|
||||
|
||||
def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
original_title = title
|
||||
|
||||
issue_number = None
|
||||
volume_nubmer = None
|
||||
resource_type = "issue"
|
||||
if "Issue " in title:
|
||||
resource_type = "issue"
|
||||
issue_number = title.split("Issue ")[1]
|
||||
volume_number = None
|
||||
if "Volume " in title:
|
||||
resource_type = "volume"
|
||||
volume_number = title.split("Volume ")[1]
|
||||
|
||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warn("No ComicVine API key configured, not looking anything up")
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
client = ComicVineClient(
|
||||
api_key=getattr(settings, "COMICVINE_API_KEY", None)
|
||||
)
|
||||
result = [
|
||||
r
|
||||
for r in client.search(title).get("results")
|
||||
if r.get("resource_type") == "volume"
|
||||
][0]
|
||||
|
||||
if "volume" not in result.keys():
|
||||
logger.warn("No result found on ComicVine", extra={"title": title})
|
||||
raw_results = client.search(title).get("results")
|
||||
results = [
|
||||
r
|
||||
for r in raw_results
|
||||
if r.get("resource_type") == resource_type
|
||||
]
|
||||
if not results:
|
||||
logger.warning("No comic found on ComicVine")
|
||||
return {}
|
||||
|
||||
title = " ".join([result.get("volume").get("name"), result.get("name)")])
|
||||
found_result = None
|
||||
for result in results:
|
||||
if result.get("issue_number") == str(issue_number):
|
||||
found_result = result
|
||||
break
|
||||
if result.get("volume_number") == str(volume_number):
|
||||
found_result = result
|
||||
break
|
||||
|
||||
if not found_result:
|
||||
found_result = results[0]
|
||||
|
||||
logger.info("ComicVine results", extra={"results": results})
|
||||
|
||||
if not found_result:
|
||||
logger.warning("No matches found on ComicVine")
|
||||
return {}
|
||||
|
||||
title = found_result.get("name")
|
||||
|
||||
if found_result.get("volume"):
|
||||
title = found_result.get("volume").get("name")
|
||||
|
||||
data_dict = {
|
||||
"title": title,
|
||||
"cover_url": result.get("image").get("original_url"),
|
||||
"comicvine_data": {
|
||||
"id": result.get("id"),
|
||||
"site_detail_url": result.get("site_detail_url"),
|
||||
"description": result.get("description"),
|
||||
"image": result.get("image").get("original_url"),
|
||||
},
|
||||
"original_title": original_title,
|
||||
"issue_number": found_result.get("issue_number"),
|
||||
"volume_number": found_result.get("volume_number"),
|
||||
"cover_url": found_result.get("image").get("original_url"),
|
||||
"comicvine_id": found_result.get("id"),
|
||||
"comicvine_data": found_result,
|
||||
"summary": found_result.get("description"),
|
||||
"publish_date": found_result.get("cover_date"),
|
||||
"first_publish_year": found_result.get("cover_date", "")[:4]
|
||||
}
|
||||
|
||||
return data_dict
|
||||
@ -29,6 +29,9 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
google_result = (
|
||||
json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
|
||||
)
|
||||
if not google_result:
|
||||
return {}
|
||||
|
||||
publish_date = pendulum.parse(google_result.get("publishedDate"))
|
||||
|
||||
isbn_13 = ""
|
||||
@ -59,13 +62,15 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
book_dict["genres"] = google_result.get("categories")
|
||||
book_dict["cover_url"] = (
|
||||
google_result.get("imageLinks", {})
|
||||
.get("thumbnail")
|
||||
.get("thumbnail", "")
|
||||
.replace("zoom=1", "zoom=15")
|
||||
.replace("&edge=curl", "")
|
||||
)
|
||||
|
||||
book_dict["run_time_seconds"] = book_dict.get("pages", 10) * getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
book_dict["base_run_time_seconds"] = 3600
|
||||
if book_dict.get("pages"):
|
||||
book_dict["base_run_time_seconds"] = book_dict.get("pages", 10) * getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
|
||||
return book_dict
|
||||
|
||||
@ -67,7 +67,7 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
|
||||
"url"
|
||||
)
|
||||
paper_dict["run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
|
||||
paper_dict["base_run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
paper_dict["author_dicts"] = result.get("authors")
|
||||
|
||||
59
vrobbler/apps/books/utils.py
Normal file
59
vrobbler/apps/books/utils.py
Normal file
@ -0,0 +1,59 @@
|
||||
import re
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from titlecase import titlecase
|
||||
|
||||
|
||||
def parse_readcomicsonline_uri(uri: str) -> tuple:
|
||||
try:
|
||||
path = uri.split("comic/")[1]
|
||||
except IndexError:
|
||||
return "", "", ""
|
||||
|
||||
parts = path.split('/')
|
||||
title = ""
|
||||
volume = 1
|
||||
page = 1
|
||||
if len(parts) == 2:
|
||||
title = titlecase(parts[0].replace("-", " "))
|
||||
volume = parts[1]
|
||||
if len(parts) == 3:
|
||||
title = titlecase(parts[0].replace("-", " "))
|
||||
volume = parts[1]
|
||||
page = parts[2]
|
||||
|
||||
return title, volume, page
|
||||
|
||||
|
||||
def get_comic_issue_url(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
parts = [p for p in parsed.path.strip('/').split('/') if p]
|
||||
|
||||
# Find the index of "comic"
|
||||
try:
|
||||
comic_index = parts.index("comic")
|
||||
except ValueError:
|
||||
raise ValueError("URL does not contain '/comic/' segment")
|
||||
|
||||
# Extract title (next part after 'comic')
|
||||
if len(parts) <= comic_index + 1:
|
||||
raise ValueError("No comic title found after '/comic/'")
|
||||
title = parts[comic_index + 1]
|
||||
|
||||
# Look for the first numeric segment after the title
|
||||
number = None
|
||||
for segment in parts[comic_index + 2:]:
|
||||
if segment.isdigit():
|
||||
number = segment
|
||||
break
|
||||
|
||||
# Build normalized path
|
||||
new_parts = ["comic", title]
|
||||
if number:
|
||||
new_parts.append(number)
|
||||
|
||||
normalized_path = "/" + "/".join(new_parts)
|
||||
|
||||
# Rebuild full URL (same scheme and host)
|
||||
simplified_url = urlunparse(parsed._replace(path=normalized_path, query='', fragment=''))
|
||||
return simplified_url
|
||||
0
vrobbler/apps/bricksets/api/__init__.py
Normal file
0
vrobbler/apps/bricksets/api/__init__.py
Normal file
8
vrobbler/apps/bricksets/api/serializers.py
Normal file
8
vrobbler/apps/bricksets/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
from bricksets.models import BrickSet
|
||||
|
||||
|
||||
class BrickSetSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BrickSet
|
||||
fields = "__all__"
|
||||
9
vrobbler/apps/bricksets/api/views.py
Normal file
9
vrobbler/apps/bricksets/api/views.py
Normal file
@ -0,0 +1,9 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from bricksets.api.serializers import BrickSetSerializer
|
||||
from bricksets.models import BrickSet
|
||||
|
||||
|
||||
class BrickSetViewSet(viewsets.ModelViewSet):
|
||||
queryset = BrickSet.objects.all().order_by("-created")
|
||||
serializer_class = BrickSetSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bricksets', '0002_alter_brickset_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='brickset',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='brickset',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='brickset',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,15 +1,25 @@
|
||||
from django.apps import apps
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BrickSetLogData
|
||||
from scrobbles.mixins import LongPlayScrobblableMixin
|
||||
|
||||
from vrobbler.apps.scrobbles.dataclasses import (
|
||||
BaseLogData,
|
||||
LongPlayLogData,
|
||||
WithPeopleLogData,
|
||||
)
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrickSetLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
|
||||
pass
|
||||
|
||||
|
||||
class BrickSet(LongPlayScrobblableMixin):
|
||||
""""""
|
||||
|
||||
|
||||
0
vrobbler/apps/foods/api/__init__.py
Normal file
0
vrobbler/apps/foods/api/__init__.py
Normal file
14
vrobbler/apps/foods/api/serializers.py
Normal file
14
vrobbler/apps/foods/api/serializers.py
Normal file
@ -0,0 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
from foods import models
|
||||
|
||||
|
||||
class FoodCategorySerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.FoodCategory
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class FoodSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Food
|
||||
fields = "__all__"
|
||||
21
vrobbler/apps/foods/api/views.py
Normal file
21
vrobbler/apps/foods/api/views.py
Normal file
@ -0,0 +1,21 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from foods.api.serializers import (
|
||||
FoodSerializer,
|
||||
FoodCategorySerializer,
|
||||
)
|
||||
from foods.models import (
|
||||
FoodCategory,
|
||||
Food,
|
||||
)
|
||||
|
||||
|
||||
class FoodCategoryViewSet(viewsets.ModelViewSet):
|
||||
queryset = FoodCategory.objects.all().order_by("-created")
|
||||
serializer_class = FoodCategorySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class FoodViewSet(viewsets.ModelViewSet):
|
||||
queryset = Food.objects.all().order_by("-created")
|
||||
serializer_class = FoodSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
18
vrobbler/apps/foods/migrations/0003_food_calories.py
Normal file
18
vrobbler/apps/foods/migrations/0003_food_calories.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-09-11 13:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('foods', '0002_alter_food_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='calories',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('foods', '0003_food_calories'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='food',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='food',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,3 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.apps import apps
|
||||
@ -6,12 +8,18 @@ from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import FoodLogData
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FoodLogData(BaseLogData, WithPeopleLogData):
|
||||
calories: Optional[int] = None
|
||||
meal: Optional[str] = None
|
||||
|
||||
|
||||
class FoodCategory(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
@ -40,6 +48,7 @@ class FoodCategory(TimeStampedModel):
|
||||
|
||||
class Food(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
calories = models.IntegerField(**BNULL)
|
||||
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
|
||||
allrecipe_image_small = ImageSpecField(
|
||||
source="allrecipe_image",
|
||||
@ -64,7 +73,8 @@ class Food(ScrobblableMixin):
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.category.name
|
||||
if self.category:
|
||||
return self.category.name
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
|
||||
8
vrobbler/apps/lifeevents/api/serializers.py
Normal file
8
vrobbler/apps/lifeevents/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from lifeevents.models import LifeEvent
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class LifeEventSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = LifeEvent
|
||||
fields = "__all__"
|
||||
10
vrobbler/apps/lifeevents/api/views.py
Normal file
10
vrobbler/apps/lifeevents/api/views.py
Normal file
@ -0,0 +1,10 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from lifeevents.api import serializers
|
||||
from lifeevents import models
|
||||
|
||||
|
||||
class LifeEventViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.LifeEvent.objects.all().order_by("-created")
|
||||
serializer_class = serializers.LifeEventSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lifeevents', '0002_alter_lifeevent_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='lifeevent',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='lifeevent',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lifeevent',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,12 +1,18 @@
|
||||
from dataclasses import dataclass
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from scrobbles.dataclasses import LifeEventLogData
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LifeEventLogData(BaseLogData, WithPeopleLogData):
|
||||
pass
|
||||
|
||||
|
||||
class LifeEvent(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
|
||||
|
||||
8
vrobbler/apps/locations/api/serializers.py
Normal file
8
vrobbler/apps/locations/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from locations import models
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class GeoLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.GeoLocation
|
||||
fields = "__all__"
|
||||
9
vrobbler/apps/locations/api/views.py
Normal file
9
vrobbler/apps/locations/api/views.py
Normal file
@ -0,0 +1,9 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from locations.api import serializers
|
||||
from locations import models
|
||||
|
||||
class GeoLocationViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.GeoLocation.objects.all().order_by("-created")
|
||||
serializer_class = serializers.GeoLocationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('locations', '0007_alter_geolocation_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='geolocation',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='geolocation',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='geolocation',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,13 @@
|
||||
from decimal import Decimal, getcontext
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -17,6 +17,9 @@ User = get_user_model()
|
||||
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
|
||||
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
|
||||
|
||||
@dataclass
|
||||
class GeoLocationLogData(BaseLogData, WithPeopleLogData):
|
||||
pass
|
||||
|
||||
class GeoLocation(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
||||
@ -38,9 +41,13 @@ class GeoLocation(ScrobblableMixin):
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"locations:geo_location_detail", kwargs={"slug": self.uuid}
|
||||
"locations:geolocation_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return GeoLocationLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, data_dict: Dict) -> "GeoLocation":
|
||||
"""Given a data dict from GPSLogger, does the heavy lifting of looking up
|
||||
|
||||
@ -8,11 +8,11 @@ urlpatterns = [
|
||||
path(
|
||||
"locations/",
|
||||
views.GeoLocationListView.as_view(),
|
||||
name="geo_locations_list",
|
||||
name="geolocation_list",
|
||||
),
|
||||
path(
|
||||
"locations/<slug:slug>/",
|
||||
views.GeoLocationDetailView.as_view(),
|
||||
name="geo_location_detail",
|
||||
name="geolocation_detail",
|
||||
),
|
||||
]
|
||||
|
||||
0
vrobbler/apps/moods/api/__init__.py
Normal file
0
vrobbler/apps/moods/api/__init__.py
Normal file
8
vrobbler/apps/moods/api/serializers.py
Normal file
8
vrobbler/apps/moods/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
from moods.models import Mood
|
||||
|
||||
|
||||
class MoodSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Mood
|
||||
fields = "__all__"
|
||||
9
vrobbler/apps/moods/api/views.py
Normal file
9
vrobbler/apps/moods/api/views.py
Normal file
@ -0,0 +1,9 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from moods.api.serializers import MoodSerializer
|
||||
from moods.models import Mood
|
||||
|
||||
|
||||
class MoodViewSet(viewsets.ModelViewSet):
|
||||
queryset = Mood.objects.all().order_by("-created")
|
||||
serializer_class = MoodSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('moods', '0003_alter_mood_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='mood',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mood',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mood',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,19 +1,25 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoodLogData(BaseLogData):
|
||||
reasons: Optional[str] = None
|
||||
|
||||
|
||||
class Mood(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
image = models.ImageField(upload_to="moods/", **BNULL)
|
||||
@ -36,7 +42,7 @@ class Mood(ScrobblableMixin):
|
||||
return str(self.uuid)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
|
||||
return reverse("moods:mood_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
|
||||
@ -5,10 +5,10 @@ app_name = "moods"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("moods/", views.MoodListView.as_view(), name="mood-list"),
|
||||
path("moods/", views.MoodListView.as_view(), name="mood_list"),
|
||||
path(
|
||||
"moods/<slug:slug>/",
|
||||
views.MoodDetailView.as_view(),
|
||||
name="mood-detail",
|
||||
name="mood_detail",
|
||||
),
|
||||
]
|
||||
|
||||
@ -50,17 +50,17 @@ class TrackAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"album",
|
||||
"primary_album",
|
||||
"artist",
|
||||
"musicbrainz_id",
|
||||
)
|
||||
raw_id_fields = (
|
||||
"album",
|
||||
"artist",
|
||||
)
|
||||
raw_id_fields = ("artist", "albums", "album")
|
||||
list_filter = ("album", "artist")
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
filter_horizontal = [
|
||||
"albums",
|
||||
]
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
@ -1,162 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pylast
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from music.models import Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PYLAST_ERRORS = tuple(
|
||||
getattr(pylast, exc_name)
|
||||
for exc_name in (
|
||||
"ScrobblingError",
|
||||
"NetworkError",
|
||||
"MalformedResponseError",
|
||||
"WSError",
|
||||
)
|
||||
if hasattr(pylast, exc_name)
|
||||
)
|
||||
|
||||
|
||||
class LastFM:
|
||||
def __init__(self, user):
|
||||
try:
|
||||
self.client = pylast.LastFMNetwork(
|
||||
api_key=getattr(settings, "LASTFM_API_KEY"),
|
||||
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
|
||||
username=user.profile.lastfm_username,
|
||||
password_hash=pylast.md5(user.profile.lastfm_password),
|
||||
)
|
||||
self.user = self.client.get_user(user.profile.lastfm_username)
|
||||
self.vrobbler_user = user
|
||||
except PYLAST_ERRORS as e:
|
||||
logger.error(f"Error during Last.fm setup: {e}")
|
||||
|
||||
def import_from_lastfm(self, last_processed=None):
|
||||
"""Given a last processed time, import all scrobbles from LastFM since then"""
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
new_scrobbles = []
|
||||
source = "Last.fm"
|
||||
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
||||
|
||||
for lfm_scrobble in lastfm_scrobbles:
|
||||
track = Track.find_or_create(
|
||||
title=lfm_scrobble.get("title"),
|
||||
artist_name=lfm_scrobble.get("artist"),
|
||||
album_name=lfm_scrobble.get("album"),
|
||||
)
|
||||
|
||||
timezone = settings.TIME_ZONE
|
||||
if self.vrobbler_user.profile:
|
||||
timezone = self.vrobbler_user.profile.timezone
|
||||
|
||||
timestamp = lfm_scrobble.get("timestamp")
|
||||
new_scrobble = Scrobble(
|
||||
user=self.vrobbler_user,
|
||||
timestamp=timestamp,
|
||||
source=source,
|
||||
track=track,
|
||||
timezone=timezone,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.TRACK,
|
||||
)
|
||||
# Vrobbler scrobbles on finish, LastFM scrobbles on start
|
||||
seconds_eariler = timestamp - timedelta(seconds=20)
|
||||
seconds_later = timestamp + timedelta(seconds=20)
|
||||
existing = Scrobble.objects.filter(
|
||||
created__gte=seconds_eariler,
|
||||
created__lte=seconds_later,
|
||||
track=track,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble {new_scrobble}")
|
||||
continue
|
||||
logger.debug(f"Queued scrobble {new_scrobble} for creation")
|
||||
new_scrobbles.append(new_scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
# TODO Add a notification for users that their import is complete
|
||||
logger.info(
|
||||
f"Last.fm import fnished",
|
||||
extra={
|
||||
"scrobbles_created": len(created),
|
||||
"user_id": self.vrobbler_user,
|
||||
"lastfm_user": self.user,
|
||||
},
|
||||
)
|
||||
return created
|
||||
|
||||
def get_last_scrobbles(self, time_from=None, time_to=None):
|
||||
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
|
||||
tracks"""
|
||||
lfm_params = {}
|
||||
scrobbles = []
|
||||
if time_from:
|
||||
lfm_params["time_from"] = int(time_from.timestamp())
|
||||
if time_to:
|
||||
lfm_params["time_to"] = int(time_to.timestamp())
|
||||
|
||||
# if not time_from and not time_to:
|
||||
lfm_params["limit"] = None
|
||||
|
||||
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
|
||||
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
|
||||
|
||||
for scrobble in found_scrobbles:
|
||||
logger.debug(f"Processing {scrobble}")
|
||||
run_time = None
|
||||
mbid = None
|
||||
artist = None
|
||||
|
||||
log_dict = {"scrobble": scrobble}
|
||||
try:
|
||||
run_time = int(scrobble.track.get_duration() / 1000)
|
||||
mbid = scrobble.track.get_mbid()
|
||||
artist = scrobble.track.get_artist().name
|
||||
log_dict["artist"] = artist
|
||||
log_dict["mbid"] = mbid
|
||||
log_dict["run_time"] = run_time
|
||||
except pylast.MalformedResponseError as e:
|
||||
logger.warning(e)
|
||||
except pylast.WSError as e:
|
||||
logger.info(
|
||||
"LastFM barfed trying to get the track for {scrobble.track}",
|
||||
extra=log_dict,
|
||||
)
|
||||
except pylast.NetworkError as e:
|
||||
logger.info(
|
||||
"LastFM barfed trying to get the track for {scrobble.track}",
|
||||
extra=log_dict,
|
||||
)
|
||||
|
||||
if not artist:
|
||||
logger.info(
|
||||
f"Silly LastFM, no artist found for scrobble",
|
||||
extra=log_dict,
|
||||
)
|
||||
continue
|
||||
|
||||
# TODO figure out if this will actually work
|
||||
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
|
||||
timestamp = datetime.utcfromtimestamp(
|
||||
int(scrobble.timestamp)
|
||||
).replace(tzinfo=pytz.utc)
|
||||
|
||||
logger.info(
|
||||
f"Scrobble appended to list for bulk create", extra=log_dict
|
||||
)
|
||||
scrobbles.append(
|
||||
{
|
||||
"artist": artist,
|
||||
"album": scrobble.album,
|
||||
"title": scrobble.track.title,
|
||||
"mbid": mbid,
|
||||
"run_time_seconds": run_time,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
return scrobbles
|
||||
|
||||
20
vrobbler/apps/music/migrations/0027_track_albums.py
Normal file
20
vrobbler/apps/music/migrations/0027_track_albums.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-20 20:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0026_album_alt_names"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="track",
|
||||
name="albums",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, null=True, related_name="tracks", to="music.album"
|
||||
),
|
||||
),
|
||||
]
|
||||
20
vrobbler/apps/music/migrations/0028_alter_track_albums.py
Normal file
20
vrobbler/apps/music/migrations/0028_alter_track_albums.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-25 14:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0027_track_albums"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="track",
|
||||
name="albums",
|
||||
field=models.ManyToManyField(
|
||||
related_name="tracks", to="music.album"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0028_alter_track_albums'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='track',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='track',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='track',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import musicbrainzngs
|
||||
@ -14,14 +15,27 @@ 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.musicbrainz import lookup_album_dict_from_mb, lookup_track_from_mb
|
||||
from music.musicbrainz import (
|
||||
get_album_metadata_with_artist,
|
||||
get_recording_mbid_exact,
|
||||
get_track_metadata_with_artist,
|
||||
)
|
||||
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
|
||||
from music.utils import clean_artist_name
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackLogData(BaseLogData):
|
||||
mopidy_source: Optional[str] = None
|
||||
rockbox_info: Optional[str] = None
|
||||
rating: Optional[int] = None
|
||||
|
||||
|
||||
class Artist(TimeStampedModel):
|
||||
"""Represents a music artist.
|
||||
|
||||
@ -161,7 +175,7 @@ class Artist(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def rym_link(self):
|
||||
artist_slug = self.name.lower().replace(" ", "-")
|
||||
artist_slug = self.name.lower().replace(" ", "-").replace(",", "")
|
||||
return f"https://rateyourmusic.com/artist/{artist_slug}/"
|
||||
|
||||
@property
|
||||
@ -170,58 +184,76 @@ class Artist(TimeStampedModel):
|
||||
return f"https://bandcamp.com/search?q={artist}&item_type=b"
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, name: str, musicbrainz_id: str = "") -> "Artist":
|
||||
from music.musicbrainz import lookup_artist_from_mb
|
||||
from music.utils import clean_artist_name
|
||||
def find_or_create(
|
||||
cls, name: str, album_name: str = "", track_name: str = ""
|
||||
) -> "Artist":
|
||||
"""The biggest challenge to finding artists is that the search often
|
||||
fails miserably unless you can look it up along with an album or a track name.
|
||||
|
||||
if not name:
|
||||
raise Exception("Must have name to lookup artist")
|
||||
Thus, when we find or create an artist, we should always provide an optional
|
||||
album name or track name, but probably not both."""
|
||||
if album_name:
|
||||
logger.info(
|
||||
f"Looking for artist with name {name} and album {album_name}"
|
||||
)
|
||||
if track_name:
|
||||
logger.info(
|
||||
f"Looking for artist with name {name} and track {track_name}"
|
||||
)
|
||||
keys = {}
|
||||
|
||||
artist = None
|
||||
name = clean_artist_name(name)
|
||||
keys["name"] = name
|
||||
artist = cls.objects.filter(name=name).first()
|
||||
|
||||
# Check for name/mbid combo, just mbid and then just name
|
||||
if musicbrainz_id:
|
||||
artist = cls.objects.filter(
|
||||
name=name, musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if not artist:
|
||||
artist = cls.objects.filter(musicbrainz_id=musicbrainz_id).first()
|
||||
if not artist:
|
||||
artist = cls.objects.filter(
|
||||
models.Q(name=name) | models.Q(alt_names__icontains=name)
|
||||
).first()
|
||||
if artist:
|
||||
return artist
|
||||
|
||||
# Does not exist, look it up from Musicbrainz
|
||||
if not artist:
|
||||
alt_name = None
|
||||
try:
|
||||
artist_dict = lookup_artist_from_mb(name)
|
||||
musicbrainz_id = musicbrainz_id or artist_dict.get("id", "")
|
||||
if name != artist_dict.get("name", ""):
|
||||
alt_name = name
|
||||
name = artist_dict.get("name", "")
|
||||
except ValueError:
|
||||
pass
|
||||
# alt_name = None
|
||||
artist_dict = {}
|
||||
if album_name:
|
||||
album_dict = get_album_metadata_with_artist(album_name, name)
|
||||
if album_dict:
|
||||
artist_dict = album_dict.get("primary_artist")
|
||||
if track_name:
|
||||
track_dict = get_track_metadata_with_artist(track_name, name)
|
||||
if track_dict:
|
||||
artist_dict = track_dict.get("primary_artist")
|
||||
|
||||
if musicbrainz_id:
|
||||
artist = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if artist and alt_name:
|
||||
if not artist.alt_names:
|
||||
artist.alt_names = alt_name
|
||||
else:
|
||||
artist.alt_names += f"\\{alt_name}"
|
||||
artist.save(update_fields=["alt_names"])
|
||||
if not artist_dict:
|
||||
artist, created = cls.objects.get_or_create(name=name)
|
||||
if created:
|
||||
artist.fix_metadata()
|
||||
return artist
|
||||
|
||||
musicbrainz_id = artist_dict.get("mbid")
|
||||
found_name = artist_dict.get("name", name)
|
||||
if found_name and name != found_name:
|
||||
alt_name = found_name
|
||||
|
||||
artist = cls.objects.filter(
|
||||
name=found_name, musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if not artist:
|
||||
artist = cls.objects.create(
|
||||
name=name, musicbrainz_id=musicbrainz_id, alt_names=alt_name
|
||||
name=found_name,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
)
|
||||
# TODO maybe this should be spun off into an async task?
|
||||
artist.fix_metadata()
|
||||
|
||||
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
|
||||
# If we did find our artist, but the found name is slightly differnt, record that
|
||||
# if artist and alt_name:
|
||||
# if not artist.alt_names:
|
||||
# artist.alt_names = alt_name
|
||||
# else:
|
||||
# artist.alt_names += f"\\{alt_name}"
|
||||
# logger.info(
|
||||
# f"Add alt_name {alt_name} to artist {artist}",
|
||||
# extra={"alt_name": alt_name, "artist_id": artist.id},
|
||||
# )
|
||||
# artist.save(update_fields=["alt_names"])
|
||||
|
||||
return artist
|
||||
|
||||
|
||||
@ -314,7 +346,7 @@ class Album(TimeStampedModel):
|
||||
)
|
||||
return
|
||||
|
||||
if not self.allmusic_id or force:
|
||||
if self.album_artist and (not self.allmusic_id or force):
|
||||
slug = get_allmusic_slug(self.album_artist.name, self.name)
|
||||
if not slug:
|
||||
logger.info(
|
||||
@ -345,7 +377,12 @@ class Album(TimeStampedModel):
|
||||
logger.info(f"No data for {self} found in TheAudioDB")
|
||||
return
|
||||
|
||||
Album.objects.filter(pk=self.pk).update(**album_data)
|
||||
try:
|
||||
Album.objects.filter(pk=self.pk).update(**album_data)
|
||||
except:
|
||||
logger.info(
|
||||
f"Could not save info for album {self} with data {album_data}"
|
||||
)
|
||||
|
||||
def scrape_bandcamp(self, force=False) -> None:
|
||||
if not self.bandcamp_id or force:
|
||||
@ -484,65 +521,77 @@ class Album(TimeStampedModel):
|
||||
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, name: str, artist_name: str, musicbrainz_id: str = ""
|
||||
) -> "Album":
|
||||
if not name or not artist_name:
|
||||
raise Exception(
|
||||
"Must have at least name and artist name to lookup album"
|
||||
def find_or_create(cls, name: str, artist_name: str) -> "Album":
|
||||
logger.info(
|
||||
f"Looking for album with name {name} and artist_name {artist_name}"
|
||||
)
|
||||
artist = Artist.find_or_create(artist_name, album_name=name)
|
||||
album_dict = get_album_metadata_with_artist(name, artist.name)
|
||||
|
||||
if not album_dict:
|
||||
logger.info(
|
||||
f"Could not find album {name} with artist {artist.name} on musicbrainz"
|
||||
)
|
||||
|
||||
album = None
|
||||
if musicbrainz_id:
|
||||
album = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
name=name,
|
||||
album_artist__name=artist_name,
|
||||
).first()
|
||||
if not album and musicbrainz_id:
|
||||
album = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
).first()
|
||||
if not album:
|
||||
album = cls.objects.filter(
|
||||
models.Q(name=name) | models.Q(alt_names__icontains=name),
|
||||
album_artist__name=artist_name,
|
||||
).first()
|
||||
|
||||
if not album:
|
||||
alt_name = None
|
||||
try:
|
||||
album_dict = lookup_album_dict_from_mb(
|
||||
name, artist_name=artist_name
|
||||
)
|
||||
musicbrainz_id = musicbrainz_id or album_dict.get("mb_id", "")
|
||||
found_name = album_dict.get("title", "")
|
||||
if found_name and name != found_name:
|
||||
alt_name = name
|
||||
name = found_name
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if musicbrainz_id:
|
||||
album = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if album and alt_name:
|
||||
if not album.alt_names:
|
||||
album.alt_names = alt_name
|
||||
else:
|
||||
album.alt_names += f"\\{alt_name}"
|
||||
album.save(update_fields=["alt_names"])
|
||||
album = Album.objects.filter(name=name).first()
|
||||
if not album:
|
||||
artist = Artist.find_or_create(name=artist_name)
|
||||
album = cls.objects.create(
|
||||
album, created = Album.objects.get_or_create(
|
||||
name=name,
|
||||
album_artist=artist,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
alt_names=alt_name,
|
||||
)
|
||||
# TODO maybe do this in a separate process?
|
||||
album.fix_metadata()
|
||||
if created:
|
||||
# album.fix_metadata()
|
||||
# album.fetch_artwork()
|
||||
...
|
||||
return album
|
||||
|
||||
if not artist:
|
||||
artist_dict = album_dict.get("primary_artist", {})
|
||||
if artist_dict:
|
||||
artist = Artist.objects.filter(
|
||||
musicbrainz_id=artist_dict.get("mbid"),
|
||||
).first()
|
||||
if not artist:
|
||||
artist = Artist.objects.create(
|
||||
musicbrainz_id=artist_dict.get("mbid"),
|
||||
)
|
||||
|
||||
extra_artists = []
|
||||
if not artist and len(album_dict.get("all_artists")) > 1:
|
||||
artist = Artist.objects.filter(name="Various Artists").first()
|
||||
extra_artists.append(artist)
|
||||
|
||||
if not artist:
|
||||
raise Exception("No album artist found, and not a compliation")
|
||||
|
||||
album = cls.objects.filter(
|
||||
models.Q(name=name) | models.Q(alt_names__icontains=name),
|
||||
album_artist=artist,
|
||||
).first()
|
||||
|
||||
alt_name = None
|
||||
found_name = album_dict.get("album_title", name)
|
||||
if found_name and name != found_name:
|
||||
alt_name = name
|
||||
|
||||
album = Album.objects.filter(
|
||||
musicbrainz_id=album_dict.get("mbid")
|
||||
).first()
|
||||
|
||||
if not album:
|
||||
year = None
|
||||
if album_dict.get("release_date"):
|
||||
year = album_dict.get("release_date", "").split("-")[0]
|
||||
album = Album.objects.create(
|
||||
name=found_name,
|
||||
musicbrainz_id=album_dict.get("mbid"),
|
||||
musicbrainz_releasegroup_id=album_dict.get(
|
||||
"release_group_mbid"
|
||||
),
|
||||
year=year,
|
||||
album_artist=artist,
|
||||
alt_names=alt_name,
|
||||
)
|
||||
album.artists.add(*extra_artists)
|
||||
album.fetch_artwork()
|
||||
|
||||
return album
|
||||
|
||||
@ -550,12 +599,8 @@ class Album(TimeStampedModel):
|
||||
class Track(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
||||
|
||||
class Opinion(models.IntegerChoices):
|
||||
DOWN = -1, "Thumbs down"
|
||||
NEUTRAL = 0, "No opinion"
|
||||
UP = 1, "Thumbs up"
|
||||
|
||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
||||
albums = models.ManyToManyField(Album, related_name="tracks")
|
||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
@ -565,6 +610,15 @@ class Track(ScrobblableMixin):
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.artist}"
|
||||
|
||||
def logdata_cls(self):
|
||||
return TrackLogData
|
||||
|
||||
@property
|
||||
def primary_album(self):
|
||||
if self.album:
|
||||
return self.album
|
||||
return self.albums.order_by("year").first()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("music:track_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@ -589,91 +643,85 @@ class Track(ScrobblableMixin):
|
||||
url = ""
|
||||
if self.artist.thumbnail:
|
||||
url = self.artist.thumbnail_medium.url
|
||||
if self.album and self.album.cover_image:
|
||||
url = self.album.cover_image_medium.url
|
||||
if self.primary_album and self.primary_album.cover_image:
|
||||
url = self.primary_album.cover_image_medium.url
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls,
|
||||
title: str = "",
|
||||
musicbrainz_id: str = "",
|
||||
album_name: str = "",
|
||||
artist_name: str = "",
|
||||
enrich: bool = True,
|
||||
run_time_seconds: Optional[int] = None,
|
||||
album_name: str = "",
|
||||
run_time_seconds: int | None = None,
|
||||
enrich: bool = False,
|
||||
commit: bool = True,
|
||||
) -> "Track":
|
||||
# TODO we can use Q to build queries here based on whether we have mbid and album name
|
||||
track = None
|
||||
# Full look up with MB ID
|
||||
"""Given a name, try to find the track by the artist from Musicbrainz.
|
||||
|
||||
As a basic conceit we trust the source for giving us the track and artist
|
||||
name
|
||||
|
||||
Optionally, we can update any found artists with overwrite."""
|
||||
album = None
|
||||
if album_name:
|
||||
track = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
title=title,
|
||||
artist__name=artist_name,
|
||||
album__name=album_name,
|
||||
).first()
|
||||
# Full look up without album
|
||||
if not track:
|
||||
track = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
title=title,
|
||||
artist__name=artist_name,
|
||||
).first()
|
||||
logger.info("Looking up album for: {album_name}")
|
||||
album = Album.find_or_create(
|
||||
name=album_name, artist_name=artist_name
|
||||
)
|
||||
artist = album.album_artist
|
||||
else:
|
||||
artist = Artist.find_or_create(artist_name, track_name=title)
|
||||
if not artist:
|
||||
artist = Artist.find_or_create(artist_name)
|
||||
|
||||
# Full look up without MB ID
|
||||
if not track:
|
||||
track = cls.objects.filter(
|
||||
title=title,
|
||||
artist__name=artist_name,
|
||||
album__name=album_name,
|
||||
).first()
|
||||
# Base look up without MB ID or album
|
||||
if not track:
|
||||
track = cls.objects.filter(
|
||||
title=title,
|
||||
artist__name=artist_name,
|
||||
).first()
|
||||
lookup_keys = {"title": title, "artist": artist}
|
||||
if run_time_seconds:
|
||||
lookup_keys["base_run_time_seconds"] = run_time_seconds
|
||||
logger.info(f"Looking up track using: {lookup_keys}")
|
||||
track = cls.objects.filter(**lookup_keys).first()
|
||||
if track:
|
||||
logger.info(
|
||||
"Found match for track by name and artist, not going to musicbrainz ",
|
||||
extra={
|
||||
"track_id": track.id,
|
||||
"title": title,
|
||||
"artist_name": artist_name,
|
||||
"run_time_seconds": run_time_seconds,
|
||||
},
|
||||
)
|
||||
return track
|
||||
|
||||
if not track and enrich:
|
||||
track_dict = lookup_track_from_mb(title, artist_name, album_name)
|
||||
musicbrainz_id = musicbrainz_id or track_dict.get("id", "")
|
||||
# TODO This only works some of the time
|
||||
# try:
|
||||
# album_name = album_name or track_dict.get("release-list")[
|
||||
# 0
|
||||
# ].get("title", "")
|
||||
# except IndexError:
|
||||
# pass
|
||||
if not run_time_seconds:
|
||||
run_time_seconds = int(
|
||||
int(track_dict.get("length", 900000)) / 1000
|
||||
track = cls.objects.filter(title=title, artist=artist).first()
|
||||
if not track:
|
||||
track, _ = cls.objects.get_or_create(title=title, artist=artist)
|
||||
|
||||
if album:
|
||||
track.albums.add(album)
|
||||
|
||||
if enrich or not track.base_run_time_seconds:
|
||||
logger.info(
|
||||
f"Enriching track {track}",
|
||||
extra={
|
||||
"title": title,
|
||||
"artist_name": artist_name,
|
||||
"track_id": track.id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
mbid, length = get_recording_mbid_exact(
|
||||
title, artist_name, album_name
|
||||
)
|
||||
if title != track_dict.get("name", "") and track_dict.get(
|
||||
"name", False
|
||||
):
|
||||
|
||||
title = track_dict.get("name", "")
|
||||
|
||||
if musicbrainz_id:
|
||||
track = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if not track:
|
||||
artist = Artist.find_or_create(name=artist_name)
|
||||
album = None
|
||||
if album_name:
|
||||
album = Album.find_or_create(
|
||||
name=album_name, artist_name=artist_name
|
||||
)
|
||||
track = cls.objects.create(
|
||||
title=title,
|
||||
album=album,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
artist=artist,
|
||||
run_time_seconds=run_time_seconds,
|
||||
)
|
||||
# TODO maybe do this in a separate process?
|
||||
track.fix_metadata()
|
||||
except Exception:
|
||||
print("No musicbrainz result found, cannot enrich")
|
||||
return track
|
||||
track.base_run_time_seconds = run_time_seconds or int(length / 1000)
|
||||
track.musicbrainz_id = mbid
|
||||
if commit:
|
||||
track.save()
|
||||
|
||||
return track
|
||||
|
||||
def fix_metadata(self, force_update=False):
|
||||
|
||||
...
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
import musicbrainzngs
|
||||
from dateutil.parser import parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
musicbrainzngs.set_useragent("Vrobbler", "1.0", "help@unbl.ink")
|
||||
|
||||
|
||||
def lookup_album_from_mb(musicbrainz_id: str) -> dict:
|
||||
release_dict = {}
|
||||
|
||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||
release_data = musicbrainzngs.get_release_by_id(
|
||||
musicbrainz_id,
|
||||
includes=["artists", "release-groups", "recordings"],
|
||||
@ -51,7 +52,6 @@ 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 = {}
|
||||
|
||||
@ -84,7 +84,6 @@ def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
|
||||
|
||||
|
||||
def lookup_artist_from_mb(artist_name: str) -> dict:
|
||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||
|
||||
try:
|
||||
top_result = musicbrainzngs.search_artists(artist=artist_name)[
|
||||
@ -104,7 +103,7 @@ def lookup_artist_from_mb(artist_name: str) -> dict:
|
||||
|
||||
|
||||
def lookup_track_from_mb(
|
||||
track_name: str, artist_mb_id: str, album_mb_id: str
|
||||
track_name: str, artist_mb_id: str, album_mb_id: str = ""
|
||||
) -> dict:
|
||||
logger.info(
|
||||
"[lookup_track_from_mb] called",
|
||||
@ -114,7 +113,6 @@ def lookup_track_from_mb(
|
||||
"album_mb_id": album_mb_id,
|
||||
},
|
||||
)
|
||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||
|
||||
try:
|
||||
results = musicbrainzngs.search_recordings(
|
||||
@ -138,3 +136,352 @@ def lookup_track_from_mb(
|
||||
return {}
|
||||
|
||||
return top_result
|
||||
|
||||
|
||||
def get_album_metadata(album_name, artist_name, strict=True) -> dict:
|
||||
"""
|
||||
Get detailed metadata for an album from MusicBrainz.
|
||||
|
||||
:param album_name: Name of the album
|
||||
:param artist_name: Name of the artist
|
||||
:param strict: If True, only exact matches on album and artist (case-insensitive)
|
||||
:return: dict with album metadata, or None if not found
|
||||
"""
|
||||
try:
|
||||
result = musicbrainzngs.search_releases(
|
||||
release=album_name, artist=artist_name, limit=5
|
||||
)
|
||||
|
||||
for release in result.get("release-list", []):
|
||||
title = release["title"]
|
||||
primary_artist = release["artist-credit"][0]["artist"]["name"]
|
||||
|
||||
title_match = title.lower() == album_name.lower()
|
||||
artist_match = primary_artist.lower() == artist_name.lower()
|
||||
|
||||
if not strict or (title_match and artist_match):
|
||||
all_artists = [
|
||||
ac["artist"]["name"]
|
||||
for ac in release["artist-credit"]
|
||||
if isinstance(ac, dict) and "artist" in ac
|
||||
]
|
||||
|
||||
return {
|
||||
"album_title": title,
|
||||
"primary_artist": primary_artist,
|
||||
"all_artists": all_artists,
|
||||
"mbid": release["id"],
|
||||
"release_date": release.get(
|
||||
"date"
|
||||
), # May be partial (e.g., just year)
|
||||
"release_group_mbid": release["release-group"]["id"],
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
except musicbrainzngs.WebServiceError as e:
|
||||
print("MusicBrainz error:", e)
|
||||
return {}
|
||||
|
||||
|
||||
def get_recording_mbid_exact(
|
||||
track_title: str, artist_name: str, album_name: str
|
||||
) -> tuple[str, int]:
|
||||
try:
|
||||
result = musicbrainzngs.search_releases(
|
||||
artist=artist_name, release=album_name, limit=1
|
||||
)
|
||||
releases = result.get("release-list", [])
|
||||
if not releases:
|
||||
raise Exception("No releases found")
|
||||
|
||||
release_id = releases[0]["id"]
|
||||
|
||||
release_data = musicbrainzngs.get_release_by_id(
|
||||
release_id, includes=["recordings"]
|
||||
)
|
||||
tracks = release_data["release"]["medium-list"][0]["track-list"]
|
||||
|
||||
for track in tracks:
|
||||
if track["recording"]["title"].lower() == track_title.lower():
|
||||
return track["recording"]["id"], int(
|
||||
track["recording"]["length"]
|
||||
)
|
||||
|
||||
raise Exception("No recording found")
|
||||
except musicbrainzngs.WebServiceError as e:
|
||||
print(f"MusicBrainz error: {e}")
|
||||
raise Exception(e)
|
||||
|
||||
|
||||
def get_artist_metadata_extended(artist_name, strict=True):
|
||||
"""
|
||||
Fetch artist metadata including MBID, name, origin, tags, and description.
|
||||
|
||||
:param artist_name: The artist's name
|
||||
:param strict: If True, only return exact name match
|
||||
:return: dict with metadata, or None if not found
|
||||
"""
|
||||
try:
|
||||
# Step 1: Search for artist
|
||||
search_results = musicbrainzngs.search_artists(
|
||||
artist=artist_name, limit=5
|
||||
)
|
||||
for artist in search_results.get("artist-list", []):
|
||||
if not strict or artist["name"].lower() == artist_name.lower():
|
||||
mbid = artist["id"]
|
||||
|
||||
# Step 2: Get detailed info about the artist
|
||||
details = musicbrainzngs.get_artist_by_id(
|
||||
mbid, includes=["tags", "url-rels"]
|
||||
)["artist"]
|
||||
|
||||
begin_date = details.get("life-span", {}).get("begin")
|
||||
area = details.get("area", {}).get("name")
|
||||
disambiguation = details.get("disambiguation")
|
||||
tags = [t["name"] for t in details.get("tag-list", [])]
|
||||
|
||||
# Step 3: Try to find a Wikipedia or Wikidata link
|
||||
description_url = None
|
||||
for rel in details.get("url-relation-list", []):
|
||||
if rel["type"] == "wikipedia":
|
||||
description_url = rel["target"]
|
||||
break
|
||||
elif rel["type"] == "wikidata":
|
||||
description_url = rel["target"]
|
||||
|
||||
return {
|
||||
"mbid": mbid,
|
||||
"name": details["name"],
|
||||
"disambiguation": disambiguation,
|
||||
"begin_date": begin_date,
|
||||
"area": area,
|
||||
"tags": tags,
|
||||
"description_url": description_url, # user can fetch summary if needed
|
||||
}
|
||||
|
||||
return None
|
||||
except musicbrainzngs.WebServiceError as e:
|
||||
print("MusicBrainz error:", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_artist_metadata_brief(artist_id):
|
||||
"""Fetch basic artist metadata by MBID."""
|
||||
try:
|
||||
details = musicbrainzngs.get_artist_by_id(
|
||||
artist_id, includes=["tags", "aliases", "url-rels"]
|
||||
)["artist"]
|
||||
|
||||
begin_date = details.get("life-span", {}).get("begin")
|
||||
area = details.get("area", {}).get("name")
|
||||
disambiguation = details.get("disambiguation")
|
||||
tags = [t["name"] for t in details.get("tag-list", [])]
|
||||
|
||||
description_url = None
|
||||
for rel in details.get("url-relation-list", []):
|
||||
if rel["type"] == "wikipedia":
|
||||
description_url = rel["target"]
|
||||
break
|
||||
elif rel["type"] == "wikidata" and not description_url:
|
||||
description_url = rel["target"]
|
||||
|
||||
return {
|
||||
"mbid": artist_id,
|
||||
"name": details["name"],
|
||||
"disambiguation": disambiguation,
|
||||
"begin_date": begin_date,
|
||||
"area": area,
|
||||
"tags": tags,
|
||||
"description_url": description_url,
|
||||
}
|
||||
|
||||
except musicbrainzngs.WebServiceError as e:
|
||||
print("MusicBrainz error (artist lookup):", e)
|
||||
return None
|
||||
|
||||
|
||||
def parse_date(date_str):
|
||||
"""Parse MusicBrainz date format into sortable datetime object."""
|
||||
if not date_str:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%Y-%m", "%Y"):
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def get_album_metadata_with_artist(album_name, artist_name, strict=True):
|
||||
"""
|
||||
Get metadata for the earliest release of an album and its primary artist.
|
||||
|
||||
:param album_name: Album title
|
||||
:param artist_name: Name of the artist
|
||||
:param strict: If True, enforce exact match for album and artist
|
||||
:return: dict with album and primary artist metadata
|
||||
"""
|
||||
try:
|
||||
result = musicbrainzngs.search_releases(
|
||||
release=album_name, artist=artist_name, limit=100
|
||||
)
|
||||
|
||||
query_album = album_name.strip().casefold()
|
||||
query_artist = artist_name.strip().casefold()
|
||||
|
||||
valid_releases = []
|
||||
for release in result.get("release-list", []):
|
||||
release_title = release["title"].strip()
|
||||
primary_artist = release["artist-credit"][0]["artist"]
|
||||
artist_name_actual = primary_artist["name"].strip()
|
||||
|
||||
if strict:
|
||||
if release_title.casefold() != query_album:
|
||||
continue
|
||||
if artist_name_actual.casefold() != query_artist:
|
||||
continue
|
||||
|
||||
release_date = parse_date(release.get("date"))
|
||||
valid_releases.append((release, release_date))
|
||||
|
||||
if not valid_releases:
|
||||
return None
|
||||
|
||||
# Sort releases by earliest release date
|
||||
valid_releases.sort(key=lambda x: x[1] or datetime.max)
|
||||
release, _ = valid_releases[0]
|
||||
|
||||
primary_artist = release["artist-credit"][0]["artist"]
|
||||
all_artists = [
|
||||
ac["artist"]["name"]
|
||||
for ac in release["artist-credit"]
|
||||
if "artist" in ac
|
||||
]
|
||||
|
||||
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
|
||||
|
||||
return {
|
||||
"album_title": release["title"],
|
||||
"primary_artist_name": primary_artist["name"],
|
||||
"all_artists": all_artists,
|
||||
"mbid": release["id"],
|
||||
"release_group_mbid": release["release-group"]["id"],
|
||||
"release_date": release.get("date"),
|
||||
"primary_artist": artist_metadata,
|
||||
}
|
||||
|
||||
except musicbrainzngs.WebServiceError as e:
|
||||
print("MusicBrainz error (album lookup):", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_artist_metadata_brief(artist_id):
|
||||
try:
|
||||
details = musicbrainzngs.get_artist_by_id(
|
||||
artist_id, includes=["tags", "aliases", "url-rels"]
|
||||
)["artist"]
|
||||
|
||||
begin_date = details.get("life-span", {}).get("begin")
|
||||
area = details.get("area", {}).get("name")
|
||||
disambiguation = details.get("disambiguation")
|
||||
tags = [t["name"] for t in details.get("tag-list", [])]
|
||||
|
||||
description_url = None
|
||||
for rel in details.get("url-relation-list", []):
|
||||
if rel["type"] == "wikipedia":
|
||||
description_url = rel["target"]
|
||||
break
|
||||
elif rel["type"] == "wikidata" and not description_url:
|
||||
description_url = rel["target"]
|
||||
|
||||
return {
|
||||
"mbid": artist_id,
|
||||
"name": details["name"],
|
||||
"disambiguation": disambiguation,
|
||||
"begin_date": begin_date,
|
||||
"area": area,
|
||||
"tags": tags,
|
||||
"description_url": description_url,
|
||||
}
|
||||
|
||||
except musicbrainzngs.WebServiceError as e:
|
||||
print("MusicBrainz error (artist lookup):", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_track_metadata_with_artist(track_title, artist_name, strict=True):
|
||||
"""
|
||||
Get metadata for the earliest-known recording of a track, including artist info.
|
||||
|
||||
:param track_title: Track title
|
||||
:param artist_name: Artist name
|
||||
:param strict: If True, match exactly (case-insensitive)
|
||||
:return: dict with track + release + artist metadata
|
||||
"""
|
||||
try:
|
||||
result = musicbrainzngs.search_recordings(
|
||||
recording=track_title, artist=artist_name, limit=100
|
||||
)
|
||||
|
||||
query_track = track_title.strip().casefold()
|
||||
query_artist = artist_name.strip().casefold()
|
||||
|
||||
valid_candidates = []
|
||||
|
||||
for recording in result.get("recording-list", []):
|
||||
rec_title = recording["title"].strip()
|
||||
artist_credit = recording["artist-credit"][0]["artist"]
|
||||
artist_name_actual = artist_credit["name"].strip()
|
||||
|
||||
if strict:
|
||||
if rec_title.casefold() != query_track:
|
||||
continue
|
||||
if artist_name_actual.casefold() != query_artist:
|
||||
continue
|
||||
|
||||
if "release-list" not in recording:
|
||||
continue
|
||||
|
||||
for release in recording["release-list"]:
|
||||
release_date = parse_date(release.get("date"))
|
||||
if release_date:
|
||||
valid_candidates.append(
|
||||
(recording["id"], release, release_date)
|
||||
)
|
||||
|
||||
if not valid_candidates:
|
||||
return None
|
||||
|
||||
# Pick the earliest release
|
||||
valid_candidates.sort(key=lambda x: x[2])
|
||||
recording_id, release, _ = valid_candidates[0]
|
||||
|
||||
# Fetch full recording info
|
||||
full_recording = musicbrainzngs.get_recording_by_id(
|
||||
recording_id, includes=["artists", "releases"]
|
||||
)["recording"]
|
||||
|
||||
primary_artist = full_recording["artist-credit"][0]["artist"]
|
||||
all_artists = [
|
||||
ac["artist"]["name"]
|
||||
for ac in full_recording["artist-credit"]
|
||||
if "artist" in ac
|
||||
]
|
||||
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
|
||||
|
||||
return {
|
||||
"track_title": full_recording["title"],
|
||||
"length_ms": full_recording.get("length"),
|
||||
"recording_mbid": recording_id,
|
||||
"release_title": release["title"],
|
||||
"release_date": release.get("date"),
|
||||
"release_group_mbid": release["release-group"]["id"],
|
||||
"primary_artist_name": primary_artist["name"],
|
||||
"all_artists": all_artists,
|
||||
"primary_artist": artist_metadata,
|
||||
}
|
||||
|
||||
except musicbrainzngs.WebServiceError as e:
|
||||
print("MusicBrainz error (track lookup):", e)
|
||||
return None
|
||||
|
||||
@ -1,184 +1,113 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from music.musicbrainz import (
|
||||
lookup_album_dict_from_mb,
|
||||
lookup_artist_from_mb,
|
||||
lookup_track_from_mb,
|
||||
)
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from music.constants import VARIOUS_ARTIST_DICT
|
||||
from scrobbles.utils import convert_to_seconds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
from music.models import Album, Artist, Track
|
||||
|
||||
|
||||
def clean_artist_name(name: str) -> str:
|
||||
"""Remove featured names from artist string."""
|
||||
if "feat." in name.lower():
|
||||
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
||||
if "featuring" in name.lower():
|
||||
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
|
||||
if "&" in name.lower():
|
||||
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||
if " feat. " in name.lower():
|
||||
name = re.split(" feat. ", name, flags=re.IGNORECASE)[0].strip()
|
||||
if " w. " in name.lower():
|
||||
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
|
||||
if " featuring " in name.lower():
|
||||
name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip()
|
||||
# if " & " in name.lower() and "of the wand" not in name.lower():
|
||||
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||
|
||||
return name
|
||||
|
||||
|
||||
# TODO These are depreacted, remove them eventually
|
||||
def get_or_create_artist(name: str, mbid: str = "") -> Artist:
|
||||
"""Get an Artist object from the database.
|
||||
def get_or_create_various_artists() -> "Artist":
|
||||
from music.models import Artist
|
||||
|
||||
Check if an artist with this name or Musicbrainz ID already exists.
|
||||
Otherwise, go lookup artist data from Musicbrainz and create one.
|
||||
|
||||
"""
|
||||
artist = None
|
||||
name = clean_artist_name(name)
|
||||
|
||||
# Check for name/mbid combo, just mbid and then just name
|
||||
artist = Artist.objects.filter(name=name, mbid=mbid).first()
|
||||
if not artist:
|
||||
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||
if not artist:
|
||||
artist = Artist.objects.filter(name=name).first()
|
||||
|
||||
# Does not exist, look it up from Musicbrainz
|
||||
if not artist:
|
||||
artist_dict = lookup_artist_from_mb(name)
|
||||
mbid = mbid or artist_dict.get("id", "")
|
||||
|
||||
if mbid:
|
||||
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||
|
||||
if not artist:
|
||||
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
|
||||
# TODO maybe this should be spun off into an async task?
|
||||
artist.fix_metadata()
|
||||
|
||||
return artist
|
||||
|
||||
|
||||
# TODO These are depreacted, remove them eventually
|
||||
def get_or_create_album(
|
||||
name: str, artist: Artist, mbid: str = None
|
||||
) -> Optional[Album]:
|
||||
album = None
|
||||
album_dict = lookup_album_dict_from_mb(name, artist_name=artist.name)
|
||||
|
||||
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(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",
|
||||
"musicbrainz_albumartist_id",
|
||||
]
|
||||
)
|
||||
album.artists.add(artist)
|
||||
album.fix_album_artist()
|
||||
album.fetch_artwork()
|
||||
album.scrape_allmusic()
|
||||
|
||||
if not album:
|
||||
logger.warn(f"No album found for {name} and {mbid}")
|
||||
|
||||
album.fix_album_artist()
|
||||
return album
|
||||
|
||||
|
||||
# TODO These are depreacted, remove them eventually
|
||||
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)
|
||||
)
|
||||
|
||||
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 = Artist.find_or_create(artist_name, artist_mb_id)
|
||||
album = None
|
||||
# We may get no album ID or title, in which case, skip
|
||||
if album_mb_id or album_title:
|
||||
album = Album.find_or_create(
|
||||
album_title, str(artist.name), 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 not track_title and not track_mb_id:
|
||||
logger.info(
|
||||
"Cannot find track without either title or MB ID",
|
||||
extra={"post_data": post_data},
|
||||
)
|
||||
return
|
||||
|
||||
if track_mb_id:
|
||||
track = Track.objects.filter(musicbrainz_id=track_mb_id).first()
|
||||
|
||||
if not track and track_title:
|
||||
track = Track.objects.filter(title=track_title, artist=artist).first()
|
||||
|
||||
if not track:
|
||||
track = Track.objects.create(
|
||||
title=track_title,
|
||||
artist=artist,
|
||||
album=album,
|
||||
musicbrainz_id=track_mb_id,
|
||||
run_time_seconds=track_run_time_seconds,
|
||||
)
|
||||
return track
|
||||
|
||||
|
||||
def get_or_create_various_artists() -> Artist:
|
||||
artist = Artist.objects.filter(name="Various Artists").first()
|
||||
if not artist:
|
||||
artist = Artist.objects.create(**VARIOUS_ARTIST_DICT)
|
||||
logger.info("Created Various Artists placeholder")
|
||||
return artist
|
||||
|
||||
|
||||
def deduplicate_tracks(commit=False) -> int:
|
||||
from music.models import Track
|
||||
|
||||
duplicates = (
|
||||
Track.objects.values("artist", "title")
|
||||
.annotate(dup_count=models.Count("id"))
|
||||
.filter(dup_count__gt=1)
|
||||
)
|
||||
|
||||
query = models.Q()
|
||||
for dup in duplicates:
|
||||
query |= models.Q(artist=dup["artist"], title=dup["title"])
|
||||
|
||||
duplicate_tracks = Track.objects.filter(query)
|
||||
|
||||
for b in duplicate_tracks:
|
||||
tracks = Track.objects.filter(artist=b.artist, title=b.title)
|
||||
first = tracks.first()
|
||||
for other in tracks.exclude(id=first.id):
|
||||
print("Moving scrobbles for", other.id, " to ", first.id)
|
||||
if commit:
|
||||
with transaction.atomic():
|
||||
other.scrobble_set.update(track=first)
|
||||
print("deleting ", other.id, " - ", other)
|
||||
try:
|
||||
other.delete()
|
||||
except IntegrityError as e:
|
||||
print(
|
||||
"could not delete ",
|
||||
other.id,
|
||||
f": IntegrityError {e}",
|
||||
)
|
||||
return len(duplicate_tracks)
|
||||
|
||||
|
||||
def condense_albums(commit: bool = False):
|
||||
from music.models import Track
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
processed_ids = []
|
||||
for track in Track.objects.all():
|
||||
albums_to_add = []
|
||||
duplicates = (
|
||||
Track.objects.filter(title=track.title, artist=track.artist)
|
||||
.exclude(id=track.id)
|
||||
.exclude(id__in=processed_ids)
|
||||
)
|
||||
|
||||
if commit and track.album:
|
||||
albums_to_add.append(track.album)
|
||||
|
||||
for dup_track in duplicates:
|
||||
logger.info(f"Adding {dup_track.album} to {track} albums")
|
||||
if commit and dup_track.album:
|
||||
track.albums.add(dup_track.album)
|
||||
|
||||
# Find out if this track appears more than once
|
||||
duplicates = Track.objects.filter(
|
||||
title=track.title, artist=track.artist
|
||||
)
|
||||
if duplicates.count() > 1:
|
||||
logger.info(f"Track appears more than once, condensing: {track}")
|
||||
|
||||
albums_to_add.extend([d.album for d in duplicates])
|
||||
# Find all scrobbles
|
||||
duplicate_ids = duplicates.values_list("id", flat=True)
|
||||
scrobbles = Scrobble.objects.filter(track_id__in=duplicate_ids)
|
||||
logger.info(
|
||||
f"Found {scrobbles.count()} scrobbles to merge onto {track}"
|
||||
)
|
||||
if commit:
|
||||
scrobbles.update(track=track)
|
||||
track.albums.add(*list(set(albums_to_add)))
|
||||
|
||||
processed_ids.extend(duplicate_ids)
|
||||
|
||||
if commit:
|
||||
Track.objects.filter(scrobble__isnull=True).delete()
|
||||
|
||||
return len(set(processed_ids))
|
||||
|
||||
0
vrobbler/apps/people/__init__.py
Normal file
0
vrobbler/apps/people/__init__.py
Normal file
10
vrobbler/apps/people/admin.py
Normal file
10
vrobbler/apps/people/admin.py
Normal file
@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
from people.models import Person
|
||||
|
||||
|
||||
@admin.register(Person)
|
||||
class PersonAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "bgg_username", "bgstats_id")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("name",)
|
||||
74
vrobbler/apps/people/migrations/0001_initial.py
Normal file
74
vrobbler/apps/people/migrations/0001_initial.py
Normal file
@ -0,0 +1,74 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-02 14:59
|
||||
|
||||
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):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Person",
|
||||
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(blank=True, max_length=100, null=True),
|
||||
),
|
||||
(
|
||||
"bgstat_id",
|
||||
models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
(
|
||||
"bgg_username",
|
||||
models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
(
|
||||
"lichess_username",
|
||||
models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
("bio", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-03 02:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("people", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="person",
|
||||
name="bgstat_id",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="person",
|
||||
name="bgstats_id",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/people/migrations/__init__.py
Normal file
0
vrobbler/apps/people/migrations/__init__.py
Normal file
20
vrobbler/apps/people/models.py
Normal file
20
vrobbler/apps/people/models.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class Person(TimeStampedModel):
|
||||
"""A non-system user model that can be optionally associated with a User."""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||
name = models.CharField(max_length=100, **BNULL)
|
||||
bgstats_id = models.UUIDField(**BNULL)
|
||||
bgg_username = models.CharField(max_length=100, **BNULL)
|
||||
lichess_username = models.CharField(max_length=100, **BNULL)
|
||||
bio = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
17
vrobbler/apps/podcasts/api/serializers.py
Normal file
17
vrobbler/apps/podcasts/api/serializers.py
Normal file
@ -0,0 +1,17 @@
|
||||
from podcasts import models
|
||||
from rest_framework import serializers
|
||||
|
||||
class ProducerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Producer
|
||||
fields = "__all__"
|
||||
|
||||
class PodcastEpisodeSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.PodcastEpisode
|
||||
fields = "__all__"
|
||||
|
||||
class PodcastSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Podcast
|
||||
fields = "__all__"
|
||||
20
vrobbler/apps/podcasts/api/views.py
Normal file
20
vrobbler/apps/podcasts/api/views.py
Normal file
@ -0,0 +1,20 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from podcasts.api import serializers
|
||||
from podcasts import models
|
||||
|
||||
|
||||
class ProducerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Producer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.ProducerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
class PodcastViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Podcast.objects.all().order_by("-created")
|
||||
serializer_class = serializers.PodcastSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
class PodcastEpisodeViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.PodcastEpisode.objects.all().order_by("-created")
|
||||
serializer_class = serializers.PodcastEpisodeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0017_podcast_podcastindex_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='podcastepisode',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='podcastepisode',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='podcastepisode',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -143,43 +143,46 @@ class PodcastEpisode(ScrobblableMixin):
|
||||
def find_or_create(
|
||||
cls,
|
||||
title: str,
|
||||
podcast_name: str,
|
||||
pub_date: str,
|
||||
number: int = 0,
|
||||
episode_num: int = 0,
|
||||
base_run_time_seconds: int = 2400,
|
||||
mopidy_uri: str = "",
|
||||
producer_name: str = "",
|
||||
run_time_seconds: int = 1800,
|
||||
podcast_name: str = "",
|
||||
podcast_producer: str = "",
|
||||
podcast_description: str = "",
|
||||
enrich: bool = True,
|
||||
) -> "PodcastEpisode":
|
||||
"""Given a data dict from Mopidy, finds or creates a podcast and
|
||||
producer before saving the epsiode so it can be scrobbled.
|
||||
|
||||
"""
|
||||
log_context={"mopidy_uri": mopidy_uri, "media_type": "Podcast"}
|
||||
producer = None
|
||||
if producer_name:
|
||||
producer = Producer.find_or_create(producer_name)
|
||||
if podcast_producer:
|
||||
producer = Producer.find_or_create(podcast_producer)
|
||||
|
||||
podcast = Podcast.objects.filter(
|
||||
name__iexact=podcast_name,
|
||||
).first()
|
||||
if not podcast:
|
||||
podcast = Podcast.objects.create(
|
||||
name=podcast_name, producer=producer
|
||||
)
|
||||
if enrich:
|
||||
podcast.fix_metadata()
|
||||
podcast, created = Podcast.objects.get_or_create(name=podcast_name, defaults={"description": podcast_description})
|
||||
log_context["podcast_id"] = podcast.id
|
||||
log_context["podcast_name"] = podcast.name
|
||||
if created:
|
||||
logger.info("Created new podcast", extra=log_context)
|
||||
if enrich and created:
|
||||
logger.info("Enriching new podcast", extra=log_context)
|
||||
podcast.fix_metadata()
|
||||
|
||||
episode = cls.objects.filter(
|
||||
title__iexact=title, podcast=podcast
|
||||
).first()
|
||||
if not episode:
|
||||
episode = cls.objects.create(
|
||||
title=title,
|
||||
podcast=podcast,
|
||||
run_time_seconds=run_time_seconds,
|
||||
number=number,
|
||||
pub_date=pub_date,
|
||||
mopidy_uri=mopidy_uri,
|
||||
)
|
||||
episode, created = cls.objects.get_or_create(
|
||||
title=title,
|
||||
podcast=podcast,
|
||||
defaults={
|
||||
"base_run_time_seconds": base_run_time_seconds,
|
||||
"number": episode_num,
|
||||
"pub_date": pub_date,
|
||||
"mopidy_uri": mopidy_uri,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
log_context["episode_id"] = episode.id
|
||||
log_context["episode_title"] = episode.title
|
||||
logger.info("Created new podcast episode", extra=log_context)
|
||||
|
||||
return episode
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import feedparser
|
||||
import requests
|
||||
from dateutil.parser import ParserError, parse
|
||||
from podcasts.models import PodcastEpisode
|
||||
|
||||
@ -10,26 +13,95 @@ logger = logging.getLogger(__name__)
|
||||
# TODO This should be configurable in settings or per deploy
|
||||
PODCAST_DATE_FORMAT = "YYYY-MM-DD"
|
||||
|
||||
def parse_duration(d):
|
||||
if not d:
|
||||
return None
|
||||
if d.isdigit():
|
||||
return int(d)
|
||||
parts = [int(p) for p in d.split(":")]
|
||||
while len(parts) < 3:
|
||||
parts.insert(0, 0)
|
||||
h, m, s = parts
|
||||
return h * 3600 + m * 60 + s
|
||||
|
||||
def fetch_metadata_from_rss(uri: str) -> dict[str, Any]:
|
||||
log_context = {"mopidy_uri": uri, "media_type": "Podcast"}
|
||||
podcast_data: dict[str, Any] = {}
|
||||
|
||||
rss_url = uri.split("#")[0].split("podcast+")[1]
|
||||
target_guid = uri.split("#")[1]
|
||||
|
||||
log_context["rss_url"] = rss_url
|
||||
log_context["target_guid"] = target_guid
|
||||
|
||||
try:
|
||||
resp = requests.get(rss_url, timeout=10)
|
||||
feed = feedparser.parse(resp.text)
|
||||
except IndexError:
|
||||
logger.warning("Tried to parse uri as RSS feed, but no target found", extra=log_context)
|
||||
return podcast_data
|
||||
|
||||
podcast_publisher = getattr(feed.feed, "itunes_publisher", "")
|
||||
try:
|
||||
podcast_owner = feed.feed.itunes_owner.get("name") if isinstance(feed.feed.itunes_owner, dict) else feed.feed.itunes_owner
|
||||
podcast_other = feed.feed.get("managingeditor") or feed.feed.get("copyright")
|
||||
except AttributeError:
|
||||
podcast_owner = None
|
||||
podcast_other = None
|
||||
|
||||
podcast_data = {
|
||||
"podcast_name": getattr(feed.feed, "title", ""),
|
||||
# "podcast_description": getattr(feed.feed, "description", ""),
|
||||
# "podcast_link": getattr(feed.feed, "link", ""),
|
||||
"podcast_producer": podcast_publisher or podcast_owner or podcast_other
|
||||
}
|
||||
|
||||
for entry in feed.entries:
|
||||
if entry.get("guid") == target_guid:
|
||||
logger.info("🎧 Episode found in RSS feed", extra=log_context)
|
||||
podcast_data["title"] = entry.title
|
||||
podcast_data["episode_num"] = int(entry.get("itunes_episode", 0))
|
||||
podcast_data["pub_date"] = parse(entry.get("published", None))
|
||||
podcast_data["base_run_time_seconds"] = parse_duration(entry.get("itunes_duration", None))
|
||||
# podcast_data["description"] = entry.get("description", None)
|
||||
# podcast_data["episode_url"] = entry.enclosures[0].href if entry.get("enclosures") else None
|
||||
return podcast_data
|
||||
else:
|
||||
logger.info("Episode not found in RSS feed.")
|
||||
|
||||
|
||||
def parse_mopidy_uri(uri: str) -> dict[str, Any]:
|
||||
podcast_data: dict[str, Any] = {}
|
||||
|
||||
def parse_mopidy_uri(uri: str) -> dict:
|
||||
logger.debug(f"Parsing URI: {uri}")
|
||||
if "podcast+https" in uri:
|
||||
return fetch_metadata_from_rss(uri)
|
||||
|
||||
|
||||
parsed_uri = os.path.splitext(unquote(uri))[0].split("/")
|
||||
|
||||
podcast_data = {
|
||||
"title": parsed_uri[-1],
|
||||
"episode_num": None,
|
||||
"podcast_name": parsed_uri[-2].strip(),
|
||||
"pub_date": None,
|
||||
}
|
||||
|
||||
|
||||
episode_str = parsed_uri[-1]
|
||||
podcast_name = parsed_uri[-2].strip()
|
||||
episode_num = None
|
||||
episode_num_pad = 0
|
||||
|
||||
try:
|
||||
# Without episode numbers the date will lead
|
||||
pub_date = parse(episode_str[0:10])
|
||||
podcast_data["pub_date"] = parse(episode_str[0:10])
|
||||
except ParserError:
|
||||
episode_num = int(episode_str.split("-")[0])
|
||||
episode_num_pad = len(str(episode_num)) + 1
|
||||
podcast_data["episode_num"] = int(episode_str.split("-")[0])
|
||||
episode_num_pad = len(str(podcast_data["episode_num"])) + 1
|
||||
|
||||
try:
|
||||
# Beacuse we have epsiode numbers on
|
||||
pub_date = parse(
|
||||
podcast_data["pub_date"] = parse(
|
||||
episode_str[
|
||||
episode_num_pad : len(PODCAST_DATE_FORMAT)
|
||||
+ episode_num_pad
|
||||
@ -39,41 +111,19 @@ def parse_mopidy_uri(uri: str) -> dict:
|
||||
pub_date = ""
|
||||
|
||||
gap_to_strip = 0
|
||||
if pub_date:
|
||||
if podcast_data["pub_date"]:
|
||||
gap_to_strip += len(PODCAST_DATE_FORMAT)
|
||||
if episode_num:
|
||||
if podcast_data["episode_num"]:
|
||||
gap_to_strip += episode_num_pad
|
||||
|
||||
episode_name = episode_str[gap_to_strip:].replace("-", " ").strip()
|
||||
podcast_data["title"] = episode_str[gap_to_strip:].replace("-", " ").strip()
|
||||
|
||||
return {
|
||||
"episode_filename": episode_name,
|
||||
"episode_num": episode_num,
|
||||
"podcast_name": podcast_name,
|
||||
"pub_date": pub_date,
|
||||
}
|
||||
return podcast_data
|
||||
|
||||
|
||||
def get_or_create_podcast(post_data: dict) -> PodcastEpisode:
|
||||
logger.info("Looking up podcast", extra={"post_data": post_data, "media_type": "Podcast"})
|
||||
mopidy_uri = post_data.get("mopidy_uri", "")
|
||||
parsed_data = parse_mopidy_uri(mopidy_uri)
|
||||
|
||||
producer_dict = {"name": post_data.get("artist")}
|
||||
|
||||
podcast_name = post_data.get("album")
|
||||
if not podcast_name:
|
||||
podcast_name = parsed_data.get("podcast_name")
|
||||
podcast_dict = {"name": podcast_name}
|
||||
|
||||
episode_name = parsed_data.get("episode_filename")
|
||||
episode_dict = {
|
||||
"title": episode_name,
|
||||
"run_time_seconds": post_data.get("run_time"),
|
||||
"number": parsed_data.get("episode_num"),
|
||||
"pub_date": parsed_data.get("pub_date"),
|
||||
"mopidy_uri": mopidy_uri,
|
||||
}
|
||||
|
||||
return PodcastEpisode.find_or_create(
|
||||
podcast_dict, producer_dict, episode_dict
|
||||
)
|
||||
return PodcastEpisode.find_or_create(**parsed_data)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from django.views import generic
|
||||
from podcasts.models import Podcast
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
class PodcastListView(generic.ListView):
|
||||
model = Podcast
|
||||
|
||||
@ -7,10 +7,13 @@ from profiles.models import UserProfile
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
ordering = ("-created",)
|
||||
readonly_fields = ("timezone_change_log",)
|
||||
exclude = (
|
||||
"twitch_token",
|
||||
"twitch_client_secret",
|
||||
"lastfm_password",
|
||||
"webdav_pass",
|
||||
"imap_pass",
|
||||
"archivebox_password",
|
||||
"todoist_auth_key",
|
||||
"todoist_state",
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-02 14:54
|
||||
|
||||
from django.db import migrations, models
|
||||
import encrypted_field.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0023_alter_userprofile_timezone"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="bgstat_id",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="imap_auto_import",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="imap_pass",
|
||||
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="imap_url",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="imap_user",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-03 02:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"profiles",
|
||||
"0024_userprofile_bgstat_id_userprofile_imap_auto_import_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="userprofile",
|
||||
old_name="bgstat_id",
|
||||
new_name="bgstats_id",
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-11 22:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('profiles', '0025_rename_bgstat_id_userprofile_bgstats_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='timezone_change_log',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.19 on 2025-07-30 22:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('profiles', '0026_userprofile_timezone_change_log'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='mood_checkin_enabled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='mood_checkin_frequency',
|
||||
field=models.CharField(default='hourly', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -1,4 +1,7 @@
|
||||
import pytz
|
||||
from zoneinfo import ZoneInfo
|
||||
import pendulum
|
||||
from django.utils import timezone
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
@ -10,6 +13,8 @@ from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||
User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserProfile(TimeStampedModel):
|
||||
user = models.OneToOneField(
|
||||
@ -18,6 +23,7 @@ class UserProfile(TimeStampedModel):
|
||||
timezone = models.CharField(
|
||||
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC"
|
||||
)
|
||||
timezone_change_log = models.TextField(**BNULL)
|
||||
lastfm_username = models.CharField(max_length=255, **BNULL)
|
||||
lastfm_password = EncryptedField(**BNULL)
|
||||
lastfm_auto_import = models.BooleanField(default=False)
|
||||
@ -31,6 +37,7 @@ class UserProfile(TimeStampedModel):
|
||||
|
||||
task_context_tags_str = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
bgstats_id = models.CharField(max_length=255, **BNULL)
|
||||
bgg_username = models.CharField(max_length=255, **BNULL)
|
||||
lichess_username = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
@ -43,6 +50,14 @@ class UserProfile(TimeStampedModel):
|
||||
webdav_pass = EncryptedField(**BNULL)
|
||||
webdav_auto_import = models.BooleanField(default=False)
|
||||
|
||||
imap_url = models.CharField(max_length=255, **BNULL)
|
||||
imap_user = models.CharField(max_length=255, **BNULL)
|
||||
imap_pass = EncryptedField(**BNULL)
|
||||
imap_auto_import = models.BooleanField(default=False)
|
||||
|
||||
mood_checkin_enabled = models.BooleanField(default=False)
|
||||
mood_checkin_frequency = models.CharField(max_length=20, default="hourly")
|
||||
|
||||
ntfy_url = models.CharField(max_length=255, **BNULL)
|
||||
ntfy_enabled = models.BooleanField(default=False)
|
||||
|
||||
@ -53,16 +68,83 @@ class UserProfile(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def tzinfo(self):
|
||||
return pytz.timezone(self.timezone)
|
||||
return ZoneInfo(self.timezone)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self._state.adding:
|
||||
old_instance = UserProfile.objects.get(pk=self.pk)
|
||||
is_timezone_change = self.timezone != old_instance.timezone
|
||||
if is_timezone_change:
|
||||
logger.info(
|
||||
"Updating timezone changelog for user",
|
||||
extra={"profile_id": self.id},
|
||||
)
|
||||
previous_changes = old_instance.timezone_change_log
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
new_log = f"{self.timezone} - {now}"
|
||||
if previous_changes:
|
||||
new_log = previous_changes + f"\n{new_log}"
|
||||
self.timezone_change_log = new_log
|
||||
super(UserProfile, self).save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def historic_timezone_changes(self) -> list:
|
||||
"""Return a list of datetimes with timezones for the specific changed time"""
|
||||
history = [pendulum.datetime(1900, 1, 1, 0, 0, 0, tz=self.tzinfo.key)]
|
||||
if self.timezone_change_log:
|
||||
for change in self.timezone_change_log.split("\n"):
|
||||
if " - " in change:
|
||||
tz, date = change.split(" - ")
|
||||
history.append(pendulum.parse(date).in_timezone(tz))
|
||||
return history
|
||||
|
||||
def get_timestamp_with_tz(self, timestamp):
|
||||
timezone = self.tzinfo
|
||||
if self.timezone_change_log:
|
||||
change_list = self.historic_timezone_changes
|
||||
for idx, start in enumerate(change_list):
|
||||
try:
|
||||
end = change_list[idx + 1]
|
||||
except IndexError:
|
||||
end = None
|
||||
|
||||
if end:
|
||||
if start <= timestamp.replace(tzinfo=end.timezone) <= end:
|
||||
timezone = start.timezone
|
||||
else:
|
||||
if start <= timestamp.replace(tzinfo=start.timezone):
|
||||
timezone = start.timezone
|
||||
|
||||
return timestamp.replace(tzinfo=timezone)
|
||||
|
||||
def adjust_timezone_of_scrobbles(self, commit=False):
|
||||
current_dt = None
|
||||
scrobbles_to_change_qs_list = []
|
||||
for boundry_dt in self.historic_timezone_changes:
|
||||
if current_dt and boundry_dt:
|
||||
logger.info(
|
||||
f"Checking for scrobbles between {current_dt} and {boundry_dt} to update to {current_dt.tzinfo.name}"
|
||||
)
|
||||
scrobbles = self.user.scrobble_set.filter(
|
||||
timestamp__gte=current_dt,
|
||||
timestamp__lt=boundry_dt,
|
||||
).exclude(timezone=current_dt.tzinfo.name)
|
||||
scrobbles_to_change_qs_list.append(scrobbles)
|
||||
logger.info(
|
||||
f"Updating {scrobbles.count()} scrobble timezones to {current_dt.tzinfo.name}"
|
||||
)
|
||||
if commit:
|
||||
scrobbles.update(timezone=current_dt.tzinfo.name)
|
||||
|
||||
current_dt = boundry_dt
|
||||
return scrobbles_to_change_qs_list
|
||||
|
||||
@cached_property
|
||||
def task_context_tags(self) -> list:
|
||||
tag_list = [
|
||||
t.strip().capitalize()
|
||||
for t in self.task_context_tags_str.split(",")
|
||||
]
|
||||
|
||||
if not tag_list:
|
||||
tag_list = settings.DEFAULT_TASK_CONTEXT_TAG_LIST
|
||||
def task_context_tags(self) -> list[str]:
|
||||
tag_list = settings.DEFAULT_TASK_CONTEXT_TAGS
|
||||
tags = ""
|
||||
if self.task_context_tags_str:
|
||||
tags = self.task_context_tags_str
|
||||
tag_list = [t.strip().capitalize() for t in tags.split(",")]
|
||||
|
||||
return tag_list
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
import calendar
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# need to translate to a non-naive timezone, even if timezone == settings.TIME_ZONE, so we can compare two dates
|
||||
def to_user_timezone(date, profile):
|
||||
@ -56,3 +57,42 @@ def end_of_month(dt, profile) -> datetime:
|
||||
|
||||
def start_of_year(dt, profile) -> datetime:
|
||||
return start_of_day(dt, profile).replace(month=1, day=1)
|
||||
|
||||
|
||||
def fix_profile_historic_timezones(profile):
|
||||
home_tz = "America/New_York"
|
||||
|
||||
europe = "2023-10-15 06:00:00"
|
||||
europe_end = "2023-12-16 12:00:00"
|
||||
europe_tz = "Europe/Paris"
|
||||
|
||||
washington = "2024-04-28 06:00:00"
|
||||
washington_end = "2024-05-04 12:00:00"
|
||||
washington_tz = "America/Los_Angeles"
|
||||
|
||||
camp = "2024-08-04 17:00:00"
|
||||
camp_end = "2024-08-10 12:00:00"
|
||||
camp_tz = "America/Halifax"
|
||||
|
||||
summer = "2025-07-09 06:00:00"
|
||||
summer_end = "2025-07-11 23:30:00"
|
||||
summer_tz = "America/Los_Angeles"
|
||||
|
||||
profile.timezone_change_log = None
|
||||
|
||||
profile.timezone_change_log = ""
|
||||
profile.timezone_change_log += f"{europe_tz} - {pendulum.parse(europe)}\n"
|
||||
profile.timezone_change_log += (
|
||||
f"{home_tz} - {pendulum.parse(europe_end)}\n"
|
||||
)
|
||||
profile.timezone_change_log += (
|
||||
f"{washington_tz} - {pendulum.parse(washington)}\n"
|
||||
)
|
||||
profile.timezone_change_log += (
|
||||
f"{home_tz} - {pendulum.parse(washington_end)}\n"
|
||||
)
|
||||
profile.timezone_change_log += f"{camp_tz} - {pendulum.parse(camp)}\n"
|
||||
profile.timezone_change_log += f"{home_tz} - {pendulum.parse(camp_end)}\n"
|
||||
profile.timezone_change_log += f"{summer_tz} - {pendulum.parse(summer)}\n"
|
||||
profile.timezone_change_log += f"{home_tz} - {pendulum.parse(summer_end)}"
|
||||
profile.save()
|
||||
|
||||
12
vrobbler/apps/puzzles/api/serializers.py
Normal file
12
vrobbler/apps/puzzles/api/serializers.py
Normal file
@ -0,0 +1,12 @@
|
||||
from puzzles import models
|
||||
from rest_framework import serializers
|
||||
|
||||
class PuzzleManufacturerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.PuzzleManufacturer
|
||||
fields = "__all__"
|
||||
|
||||
class PuzzleSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Puzzle
|
||||
fields = "__all__"
|
||||
15
vrobbler/apps/puzzles/api/views.py
Normal file
15
vrobbler/apps/puzzles/api/views.py
Normal file
@ -0,0 +1,15 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from puzzles.api import serializers
|
||||
from puzzles import models
|
||||
|
||||
class PuzzleManufacturerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.PuzzleManufacturer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.PuzzleManufacturerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class PuzzleViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Puzzle.objects.all().order_by("-created")
|
||||
serializer_class = serializers.PuzzleSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('puzzles', '0003_rename_igdb_id_puzzle_ipdb_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='puzzle',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='puzzle',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='puzzle',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,3 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
@ -9,12 +11,17 @@ from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from puzzles.sources import ipdb
|
||||
from scrobbles.dataclasses import PuzzleLogData
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData, LongPlayLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PuzzleLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class PuzzleManufacturer(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
@ -58,6 +65,10 @@ class Puzzle(ScrobblableMixin):
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.pieces_count}) by {self.manufacturer}"
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return PuzzleLogData
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.manufacturer.name
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user