Add basic views for books and games

This commit is contained in:
2023-03-06 00:54:08 -05:00
parent 9bcd9d8bb7
commit 3fc716420c
20 changed files with 228 additions and 48 deletions

View File

@ -9,7 +9,8 @@ from scrobbles.admin import ScrobbleInline
class AuthorAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "openlibrary_id")
ordering = ("name",)
ordering = ("-created",)
search_fields = ("name",)
@admin.register(Book)
@ -22,7 +23,8 @@ class BookAdmin(admin.ModelAdmin):
"pages",
"openlibrary_id",
)
ordering = ("title",)
search_fields = ("name",)
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

View File

@ -61,19 +61,20 @@ def process_koreader_sqlite_file(sqlite_file_path, user_id):
logger.debug(f"Found author {author}, created: {created}")
book, created = Book.objects.get_or_create(
koreader_md5=book_row[KoReaderBookColumn.MD5.value]
title=book_row[KoReaderBookColumn.TITLE.value]
)
if created:
book.title = book_row[KoReaderBookColumn.TITLE.value]
book.pages = book_row[KoReaderBookColumn.PAGES.value]
book.koreader_md5 = book_row[KoReaderBookColumn.MD5.value]
book.koreader_id = int(book_row[KoReaderBookColumn.ID.value])
book.koreader_authors = book_row[KoReaderBookColumn.AUTHORS.value]
book.run_time_ticks = int(book_row[KoReaderBookColumn.PAGES.value])
book.save(
update_fields=[
"title",
"pages",
"koreader_md5",
"koreader_id",
"koreader_authors",
]

View File

@ -30,7 +30,6 @@ class Book(ScrobblableMixin):
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author)
openlibrary_id = models.CharField(max_length=255, **BNULL)
goodreads_id = models.CharField(max_length=255, **BNULL)
koreader_id = models.IntegerField(**BNULL)
koreader_authors = models.CharField(max_length=255, **BNULL)
@ -39,6 +38,10 @@ class Book(ScrobblableMixin):
pages = models.IntegerField(**BNULL)
language = models.CharField(max_length=4, **BNULL)
first_publish_year = models.IntegerField(**BNULL)
author_name = models.CharField(max_length=255, **BNULL)
author_openlibrary_id = models.CharField(max_length=255, **BNULL)
openlibrary_id = models.CharField(max_length=255, **BNULL)
cover = models.ImageField(upload_to="books/covers/", **BNULL)
def __str__(self):
return f"{self.title} by {self.author}"
@ -71,3 +74,11 @@ class Book(ScrobblableMixin):
user = User.objects.get(id=user_id)
last_scrobble = get_scrobbles_for_media(self, user).last()
return int((last_scrobble.book_pages_read / self.pages) * 100)
@classmethod
def find_or_create(cls, data_dict: dict) -> "Game":
from books.utils import get_or_create_book
return get_or_create_book(
data_dict.get("title"), data_dict.get("author")
)

View File

@ -6,6 +6,8 @@ logger = logging.getLogger(__name__)
SEARCH_URL = "https://openlibrary.org/search.json?title={title}"
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
SEARCH_URL = "https://openlibrary.org/search.json?title={title}"
COVER_URL = "https://covers.openlibrary.org/b/olid/{id}-L.jpg"
def get_first(key: str, result: dict) -> str:
@ -34,7 +36,7 @@ def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
logger.warn(
f"Lookup for {title} found top result with mismatched author"
)
ol_id = top.get("cover_edition_key")
return {
"title": top.get("title"),
"isbn": top.get("isbn")[0],
@ -43,4 +45,6 @@ def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
"author_openlibrary_id": get_first("author_key", top),
"goodreads_id": get_first("id_goodreads", top),
"first_publish_year": top.get("first_publish_year"),
"pages": top.get("number_of_pages_median"),
"cover_url": COVER_URL.format(id=ol_id),
}

View File

@ -0,0 +1,28 @@
from django.core.files.base import ContentFile
import requests
from typing import Optional
from books.openlibrary import lookup_book_from_openlibrary
from books.models import Book
def get_or_create_book(title: str, author: Optional[str] = None) -> Book:
book_dict = lookup_book_from_openlibrary(title, author)
book, book_created = Book.objects.get_or_create(
isbn=book_dict.get("isbn"),
)
if book_created:
cover_url = book_dict.pop("cover_url")
Book.objects.filter(pk=book.id).update(**book_dict)
book.refresh_from_db()
r = requests.get(cover_url)
if r.status_code == 200:
fname = f"{book.title}_{book.uuid}.jpg"
book.cover.save(fname, ContentFile(r.content), save=True)
book.fix_metadata()
return book

View File

@ -20,7 +20,8 @@ class AlbumAdmin(admin.ModelAdmin):
"theaudiodb_score",
"theaudiodb_genre",
)
ordering = ("name",)
ordering = ("-created",)
search_fields = ("name",)
filter_horizontal = [
"artists",
]
@ -39,7 +40,8 @@ class ArtistAdmin(admin.ModelAdmin):
"theaudiodb_mood",
"theaudiodb_genre",
)
ordering = ("name",)
search_fields = ("name",)
ordering = ("-created",)
@admin.register(Track)

View File

@ -11,7 +11,15 @@ from scrobbles.models import (
class ScrobbleInline(admin.TabularInline):
model = Scrobble
extra = 0
raw_id_fields = ("video", "podcast_episode", "track")
raw_id_fields = (
"video",
"podcast_episode",
"track",
"video_game",
"book",
"sport_event",
"user",
)
exclude = ("source_id", "scrobble_log")
@ -81,7 +89,11 @@ class ScrobbleAdmin(admin.ModelAdmin):
"book",
"video_game",
)
list_filter = ("is_paused", "in_progress", "source", "track__artist")
list_filter = (
"is_paused",
"in_progress",
"source",
)
ordering = ("-timestamp",)
def media_name(self, obj):

View File

@ -1,2 +1,4 @@
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
LONG_PLAY_MEDIA = ["VideoGame", "Book"]

View File

@ -18,7 +18,7 @@ class ScrobbleForm(forms.Form):
widget=forms.TextInput(
attrs={
"class": "form-control form-control-dark w-100",
"placeholder": "Scrobble something (IMDB ID, String, TVDB ID ...)",
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID)",
"aria-label": "Scrobble something",
}
),

View File

@ -20,6 +20,7 @@ from profiles.utils import (
start_of_month,
start_of_week,
)
from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.stats import build_charts
from scrobbles.utils import check_scrobble_for_finish
from sports.models import SportEvent
@ -403,7 +404,7 @@ class Scrobble(TimeStampedModel):
# Time keeping
timestamp = models.DateTimeField(**BNULL)
playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
playback_position = models.CharField(max_length=8, **BNULL)
playback_position = models.CharField(max_length=10, **BNULL)
# Status indicators
is_paused = models.BooleanField(default=False)
@ -417,6 +418,7 @@ class Scrobble(TimeStampedModel):
# Fields for keeping track long content like books and games
book_pages_read = models.IntegerField(**BNULL)
video_game_minutes_played = models.IntegerField(**BNULL)
long_play_complete = models.BooleanField(**BNULL)
def save(self, *args, **kwargs):
@ -468,6 +470,9 @@ class Scrobble(TimeStampedModel):
@property
def can_be_updated(self) -> bool:
updatable = True
if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA:
logger.info(f"No - Long play media")
updatable = False
if self.percent_played > 100:
logger.info(f"No - 100% played - {self.id} - {self.source}")
updatable = False
@ -589,6 +594,39 @@ class Scrobble(TimeStampedModel):
self.in_progress = False
self.save(update_fields=["in_progress"])
logger.info(f"{self.id} - {self.source}")
class_name = self.media_obj.__class__.__name__
if class_name in LONG_PLAY_MEDIA:
logger.debug(
"Syncing long play media playback time to elapsed time since start"
)
now = timezone.now()
updated_playback = (now - self.timestamp).seconds / 60
media_filter = models.Q(video_game=self.video_game)
if class_name == "Book":
media_filter = models.Q(book=self.book)
last_scrobble = Scrobble.objects.filter(
media_filter,
user_id=self.user,
played_to_completion=True,
long_play_complete=False,
).last()
self.video_game_minutes_played = (
int(last_scrobble.playback_position) + updated_playback
)
self.playback_position = int(updated_playback)
self.played_to_completion = True
self.save(
update_fields=[
"playback_position",
"playback_position_ticks",
"played_to_completion",
"video_game_minutes_played",
]
)
return
check_scrobble_for_finish(self, force_finish)
def pause(self) -> None:

View File

@ -16,6 +16,7 @@ from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
from sports.models import SportEvent
from videos.models import Video
from videogames.models import VideoGame
from books.models import Book
logger = logging.getLogger(__name__)
@ -206,19 +207,38 @@ def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
def manual_scrobble_video_game(data_dict: dict, user_id: Optional[int]):
game = VideoGame.find_or_create(data_dict)
last_scrobble = Scrobble.objects.filter(
video_game=game, user_id=user_id, played_to_completion=True
).last()
playback_position = 0
if last_scrobble and not last_scrobble.playback_position:
playback_position = last_scrobble.playback_position + (30 * 60)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_ticks": playback_position * 1000,
"playback_position": playback_position,
"playback_position_ticks": None, # int(start_playback_position) * 1000,
"playback_position": 0,
"source": "Vrobbler",
"long_play_complete": False,
}
return Scrobble.create_or_update(game, user_id, scrobble_dict)
def manual_scrobble_book(data_dict: dict, user_id: Optional[int]):
book = Book.find_or_create(data_dict)
last_scrobble = Scrobble.objects.filter(
book=book,
user_id=user_id,
played_to_completion=True,
long_play_complete=False,
).last()
start_playback_position = 0
if last_scrobble:
start_playback_position = last_scrobble.playback_position or 0
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_ticks": int(start_playback_position) * 1000,
"playback_position": start_playback_position,
"source": "Vrobbler",
"long_play_complete": False,
}
return Scrobble.create_or_update(book, user_id, scrobble_dict)

View File

@ -44,6 +44,7 @@ from scrobbles.models import (
from scrobbles.scrobblers import (
jellyfin_scrobble_track,
jellyfin_scrobble_video,
manual_scrobble_book,
manual_scrobble_event,
manual_scrobble_video,
manual_scrobble_video_game,
@ -58,6 +59,7 @@ from scrobbles.tasks import (
from sports.thesportsdb import lookup_event_from_thesportsdb
from videos.imdb import lookup_video_from_imdb
from videogames.howlongtobeat import lookup_game_from_hltb
from vrobbler.apps.books.openlibrary import lookup_book_from_openlibrary
logger = logging.getLogger(__name__)
@ -181,22 +183,29 @@ class ManualScrobbleView(FormView):
item_id = form.cleaned_data.get("item_id")
data_dict = None
if "tt" in item_id:
data_dict = lookup_video_from_imdb(item_id)
if data_dict:
manual_scrobble_video(data_dict, self.request.user.id)
if not data_dict:
if "-v" in item_id or not data_dict:
logger.debug(f"Looking for video game with ID {item_id}")
data_dict = lookup_game_from_hltb(item_id.replace("-v", ""))
if data_dict:
manual_scrobble_video_game(data_dict, self.request.user.id)
if "-b" in item_id and not data_dict:
logger.debug(f"Looking for book with ID {item_id}")
data_dict = lookup_book_from_openlibrary(item_id.replace("-b", ""))
if data_dict:
manual_scrobble_book(data_dict, self.request.user.id)
if "-s" in item_id and not data_dict:
logger.debug(f"Looking for sport event with ID {item_id}")
data_dict = lookup_event_from_thesportsdb(item_id)
if data_dict:
manual_scrobble_event(data_dict, self.request.user.id)
if not data_dict:
logger.debug(f"Looking for video game with ID {item_id}")
data_dict = lookup_game_from_hltb(item_id)
if "tt" in item_id:
data_dict = lookup_video_from_imdb(item_id)
if data_dict:
manual_scrobble_video_game(data_dict, self.request.user.id)
manual_scrobble_video(data_dict, self.request.user.id)
return HttpResponseRedirect(reverse("vrobbler-home"))

View File

@ -1,12 +1,12 @@
from django.contrib import admin
from videogames.models import VideoGame, VideoGameCollection
from videogames.models import VideoGame, VideoGamePlatform
from scrobbles.admin import ScrobbleInline
@admin.register(VideoGameCollection)
class VideoGameCollectionAdmin(admin.ModelAdmin):
@admin.register(VideoGamePlatform)
class VideoGamePlatformAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
@ -26,6 +26,10 @@ class GameAdmin(admin.ModelAdmin):
"main_story_time",
"release_year",
)
search_fields = (
"title",
"alternative_name",
)
ordering = ("-created",)
inlines = [
ScrobbleInline,

View File

@ -36,6 +36,23 @@ def get_igdb_token() -> str:
return results.get("access_token")
def lookup_game_id_from_gdb(name: str) -> str:
headers = {
"Authorization": f"Bearer {get_igdb_token()}",
"Client-ID": IGDB_CLIENT_ID,
}
body = f'fields name,game,published_at; search "{name}"; limit 20;'
response = requests.post(SEARCH_URL, data=body, headers=headers)
results = json.loads(response.content)
if not results:
logger.warn(f"Search of game on IGDB failed for name {name}")
return ""
return results[0]["game"]
def lookup_game_from_igdb(igdb_id: str) -> Dict:
"""Given credsa and an IGDB game ID, lookup the game metadata and return it
in a dictionary mapped to our internal game fields
@ -45,7 +62,7 @@ def lookup_game_from_igdb(igdb_id: str) -> Dict:
"Authorization": f"Bearer {get_igdb_token()}",
"Client-ID": IGDB_CLIENT_ID,
}
fields = "id,name,alternative_names.*,release_dates.*,cover.*,screenshots.*,rating,rating_count"
fields = "id,name,alternative_names.*,release_dates.*,cover.*,screenshots.*,rating,rating_count,summary"
game_dict = {}
body = f"fields {fields}; where id = {igdb_id};"
@ -87,6 +104,7 @@ def lookup_game_from_igdb(igdb_id: str) -> Dict:
"rating": game.get("rating"),
"rating_count": game.get("rating_count"),
"release_date": release_date,
"summary": game.get("summary"),
}
return game_dict

View File

@ -1,4 +1,5 @@
import logging
from typing import Optional
from uuid import uuid4
from django.conf import settings
@ -8,6 +9,7 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
from scrobbles.utils import get_scrobbles_for_media
from videogames.igdb import lookup_game_id_from_gdb
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@ -72,6 +74,7 @@ class VideoGame(ScrobblableMixin):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
cover = models.ImageField(upload_to="games/covers/", **BNULL)
screenshot = models.ImageField(upload_to="games/screenshots/", **BNULL)
summary = models.TextField(**BNULL)
hltb_cover = models.ImageField(upload_to="games/hltb_covers/", **BNULL)
rating = models.FloatField(**BNULL)
rating_count = models.IntegerField(**BNULL)
@ -94,6 +97,14 @@ class VideoGame(ScrobblableMixin):
def hltb_link(self):
return f"https://howlongtobeat.com/game/{self.hltb_id}"
def igdb_link(self):
slug = self.title.lower().replace(" ", "-")
return f"https://igdb.com/games/{slug}"
def save(self, **kwargs):
super().save(**kwargs)
self.fix_metadata()
@property
def seconds_for_completion(self) -> int:
completion_time = self.run_time_ticks
@ -112,10 +123,7 @@ class VideoGame(ScrobblableMixin):
sec_played = last_scrobble.playback_position * 60
return int(sec_played / self.run_time) * 100
def fix_metadata(
self,
force_update: bool = False,
):
def fix_metadata(self, force_update: bool = False):
from videogames.utils import (
get_or_create_videogame,
load_game_data_from_igdb,
@ -125,6 +133,9 @@ class VideoGame(ScrobblableMixin):
get_or_create_videogame(str(self.hltb_id), force_update)
if self.igdb_id:
# This almost never works without intervention
# self.igdb_id = lookup_game_id_from_gdb(self.title)
# self.save(update_fields=["igdb_id"])
load_game_data_from_igdb(self.id)
if (not self.run_time_ticks or force_update) and self.main_story_time:

View File

@ -5,10 +5,17 @@ app_name = "videogames"
urlpatterns = [
path("game/", views.VideoGameListView.as_view(), name="videogame_list"),
path(
"game/<slug:slug>/",
"video-game/", views.VideoGameListView.as_view(), name="videogame_list"
),
path(
"video-game/<slug:slug>/",
views.VideoGameDetailView.as_view(),
name="videogame_detail",
),
path(
"video-game-platform/<slug:slug>/",
views.VideoGamePlatformDetailView.as_view(),
name="platform_detail",
),
]

View File

@ -54,21 +54,25 @@ def get_or_create_videogame(
return game
def load_game_data_from_igdb(game_id: int) -> Optional[VideoGame]:
def load_game_data_from_igdb(
game_id: int, igdb_id: str = ""
) -> Optional[VideoGame]:
"""Look up game, if it doesn't exist, lookup data from igdb"""
game = VideoGame.objects.filter(id=game_id).first()
if not game:
logger.warn(f"Video game with ID {game_id} not found")
return
if not game.igdb_id:
if not game.igdb_id and not igdb_id:
logger.warn(f"Video game has no IGDB ID")
return
logger.info(f"Looking up video game {game.igdb_id}")
game_dict = lookup_game_from_igdb(game.igdb_id)
igdb_id = game.igdb_id or igdb_id
logger.info(f"Looking up video game {igdb_id}")
game_dict = lookup_game_from_igdb(igdb_id)
if not game_dict:
logger.warn(f"No game data found on IGDB for ID {game.igdb_id}")
logger.warn(f"No game data found on IGDB for ID {igdb_id}")
return
screenshot_url = game_dict.pop("screenshot_url")

View File

@ -1,5 +1,5 @@
from django.views import generic
from videogames.models import VideoGame
from videogames.models import VideoGame, VideoGamePlatform
class VideoGameListView(generic.ListView):
@ -10,3 +10,8 @@ class VideoGameListView(generic.ListView):
class VideoGameDetailView(generic.DetailView):
model = VideoGame
slug_field = "uuid"
class VideoGamePlatformDetailView(generic.DetailView):
model = VideoGamePlatform
slug_field = "uuid"

View File

@ -307,11 +307,11 @@ LOGGING = {
"loggers": {
# Quiet down our console a little
"django": {
"handlers": ["console"],
"handlers": ["null"],
"propagate": True,
},
"django.db.backends": {"handlers": ["null"]},
"django.server": {"handlers": ["console"]},
"django.server": {"handlers": ["null"]},
"pylast": {"handlers": ["null"], "propagate": False},
"musicbrainzngs": {"handlers": ["null"], "propagate": False},
"httpx": {"handlers": ["null"], "propagate": False},

View File

@ -5,6 +5,7 @@ from rest_framework import routers
import vrobbler.apps.scrobbles.views as scrobbles_views
from vrobbler.apps.books.api.views import AuthorViewSet, BookViewSet
from vrobbler.apps.music import urls as music_urls
from vrobbler.apps.books import urls as book_urls
from vrobbler.apps.sports import urls as sports_urls
from vrobbler.apps.videogames import urls as videogame_urls
from vrobbler.apps.music.api.views import (
@ -58,6 +59,7 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("", include(music_urls, namespace="music")),
path("", include(book_urls, namespace="books")),
path("", include(video_urls, namespace="videos")),
path("", include(videogame_urls, namespace="videogames")),
path("", include(sports_urls, namespace="sports")),