Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
5265
poetry.lock
generated
5265
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"
|
||||
@ -56,6 +56,9 @@ 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(
|
||||
|
||||
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(max_length=5, **BNULL)
|
||||
volume_number = models.IntegerField(max_length=5, **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,19 @@ 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
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class FoodCategory(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
@ -40,6 +49,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 +74,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,19 @@ 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 JSONDataclass
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PuzzleLogData(JSONDataclass):
|
||||
with_people: Optional[int] = None
|
||||
rating: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class PuzzleManufacturer(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
@ -58,6 +67,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
|
||||
|
||||
@ -102,7 +102,7 @@ class ChartRecordAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Scrobble)
|
||||
class ScrobbleAdmin(admin.ModelAdmin):
|
||||
# date_hierarchy = "timestamp"
|
||||
date_hierarchy = "timestamp"
|
||||
list_display = (
|
||||
"timestamp",
|
||||
"media_name",
|
||||
@ -112,6 +112,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
"in_progress",
|
||||
"is_paused",
|
||||
"played_to_completion",
|
||||
"user",
|
||||
)
|
||||
raw_id_fields = (
|
||||
"video",
|
||||
@ -139,6 +140,8 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
"media_type",
|
||||
"long_play_complete",
|
||||
"source",
|
||||
"timezone",
|
||||
"user",
|
||||
)
|
||||
ordering = ("-timestamp",)
|
||||
|
||||
@ -147,3 +150,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
|
||||
def playback_percent(self, obj):
|
||||
return obj.percent_played
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request).exclude(timestamp__year=None)
|
||||
return qs
|
||||
|
||||
@ -18,6 +18,9 @@ PLAY_AGAIN_MEDIA = {
|
||||
"bricksets": "BrickSet",
|
||||
"trails": "Trail",
|
||||
"beers": "Beer",
|
||||
"foods": "Food",
|
||||
"locations": "GeoLocation",
|
||||
"videos": "Video",
|
||||
}
|
||||
|
||||
MEDIA_END_PADDING_SECONDS = {
|
||||
@ -35,6 +38,7 @@ SCROBBLE_CONTENT_URLS = {
|
||||
"-t": ["https://app.todoist.com/app/task/{id}"],
|
||||
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
|
||||
"-l": ["https://brickset.com/sets/"],
|
||||
"-c": ["https://readcomicsonline.ru"],
|
||||
}
|
||||
|
||||
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
|
||||
@ -50,6 +54,7 @@ MANUAL_SCROBBLE_FNS = {
|
||||
"-t": "manual_scrobble_task",
|
||||
"-p": "manual_scrobble_puzzle",
|
||||
"-l": "manual_scrobble_brickset",
|
||||
"-c": "manual_scrobble_book",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
from functools import cached_property
|
||||
import inspect
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Optional
|
||||
|
||||
from dataclass_wizard import JSONWizard
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from locations.models import GeoLocation
|
||||
from people.models import Person
|
||||
from scrobbles.forms import form_from_dataclass
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -32,189 +32,54 @@ class JSONDataclass(JSONWizard):
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScrobbleLogData(JSONDataclass):
|
||||
class BaseLogData(JSONDataclass):
|
||||
description: Optional[str] = None
|
||||
notes: Optional[list[str]] = None
|
||||
|
||||
_excluded_fields = {}
|
||||
|
||||
@classmethod
|
||||
def form(cls):
|
||||
return form_from_dataclass(cls)
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LongPlayLogData(JSONDataclass):
|
||||
serial_scrobble_id: Optional[int]
|
||||
long_play_complete: bool = False
|
||||
|
||||
|
||||
class WithOthersLogData(JSONDataclass):
|
||||
with_user_ids: Optional[list[int]] = None
|
||||
with_names_str: Optional[list[str]] = None
|
||||
@dataclass
|
||||
class WithPeopleLogData(JSONDataclass):
|
||||
with_people_ids: Optional[list[int]] = None
|
||||
|
||||
@property
|
||||
def with_names(self) -> list[str]:
|
||||
with_names = []
|
||||
if self.with_user_ids:
|
||||
with_names += [u.full_name for u in self.with_users if u]
|
||||
if self.with_names_str:
|
||||
with_names += [u for u in self.with_names_str]
|
||||
return with_names
|
||||
def with_people(self) -> list["Person"]:
|
||||
from people.models import Person
|
||||
|
||||
@property
|
||||
def with_users(self) -> list[User]:
|
||||
with_users = []
|
||||
if self.with_user_ids:
|
||||
with_users = [
|
||||
User.objects.filter(id=i).first() for i in self.with_user_ids
|
||||
]
|
||||
return with_users
|
||||
if not self.with_people_ids:
|
||||
return []
|
||||
return [
|
||||
Person.objects.filter(id=pid).first()
|
||||
for pid in self.with_people_ids
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoardGameScoreLogData(JSONDataclass):
|
||||
user_id: Optional[int] = None
|
||||
name_str: str = ""
|
||||
bgg_username: str = ""
|
||||
color: Optional[str] = None
|
||||
character: Optional[str] = None
|
||||
team: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
win: Optional[bool] = None
|
||||
new: Optional[bool] = None
|
||||
|
||||
@property
|
||||
def user(self) -> Optional[User]:
|
||||
user = None
|
||||
if self.user_id:
|
||||
user = User.objects.filter(id=self.user_id).first()
|
||||
return user
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
name = self.name_str
|
||||
if self.user_id:
|
||||
name = self.user.first_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(LongPlayLogData):
|
||||
serial_scrobble_id: Optional[int] = None
|
||||
long_play_complete: Optional[bool] = None
|
||||
players: Optional[list[BoardGameScoreLogData]] = None
|
||||
location: Optional[str] = None
|
||||
geo_location_id: Optional[int] = None
|
||||
difficulty: Optional[int] = None
|
||||
solo: Optional[bool] = None
|
||||
two_handed: Optional[bool] = None
|
||||
|
||||
@cached_property
|
||||
def geo_location(self) -> Optional[GeoLocation]:
|
||||
if self.geo_location_id:
|
||||
return GeoLocation.objects.filter(id=self.geo_location_id).first()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookPageLogData(JSONDataclass):
|
||||
page_number: Optional[int] = None
|
||||
end_ts: Optional[int] = None
|
||||
start_ts: Optional[int] = None
|
||||
duration: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookLogData(LongPlayLogData):
|
||||
long_play_complete: Optional[bool] = None
|
||||
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
|
||||
serial_scrobble_id: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LifeEventLogData(WithOthersLogData):
|
||||
with_user_ids: Optional[list[int]] = None
|
||||
with_names_str: Optional[list[str]] = None
|
||||
location: Optional[str] = None
|
||||
geo_location_id: Optional[int] = None
|
||||
details: Optional[str] = None
|
||||
|
||||
def geo_location(self):
|
||||
return GeoLocation.objects.filter(id=self.geo_location_id).first()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoodLogData(JSONDataclass):
|
||||
reasons: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoLogData(JSONDataclass):
|
||||
title: str
|
||||
video_type: str
|
||||
run_time_seconds: int
|
||||
kind: str
|
||||
year: Optional[int]
|
||||
episode_number: Optional[int] = None
|
||||
source_url: Optional[str] = None
|
||||
imdbID: Optional[str] = None
|
||||
season_number: Optional[int] = None
|
||||
cover_url: Optional[str] = None
|
||||
next_imdb_id: Optional[int] = None
|
||||
tv_series_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoGameLogData(LongPlayLogData):
|
||||
serial_scrobble_id: Optional[int] = None
|
||||
long_play_complete: Optional[bool] = False
|
||||
console: Optional[str] = None
|
||||
emulated: Optional[bool] = False
|
||||
emulator: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrickSetLogData(LongPlayLogData, WithOthersLogData):
|
||||
serial_scrobble_id: Optional[int]
|
||||
long_play_complete: bool = False
|
||||
with_user_ids: Optional[list[int]] = None
|
||||
with_names_str: Optional[list[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrailLogData(WithOthersLogData):
|
||||
with_user_ids: Optional[list[int]] = None
|
||||
with_names_str: Optional[list[str]] = None
|
||||
details: Optional[str] = None
|
||||
effort: Optional[str] = None
|
||||
difficulty: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeerLogData(WithOthersLogData):
|
||||
with_user_ids: Optional[list[int]] = None
|
||||
with_names_str: Optional[list[str]] = None
|
||||
details: Optional[str] = None
|
||||
rating: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FoodLogData(JSONDataclass):
|
||||
meal: Optional[str] = None
|
||||
details: Optional[str] = None
|
||||
rating: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PuzzleLogData(JSONDataclass):
|
||||
with_others: Optional[str] = None
|
||||
rating: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
@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 = {
|
||||
"with_people_ids": forms.ModelMultipleChoiceField(
|
||||
queryset=Person.objects.all(),
|
||||
required=False,
|
||||
widget=forms.SelectMultiple(attrs={"size": 10}),
|
||||
)
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user