ソースを参照

Add basic views for books and games

Colin Powell 2 年 前
コミット
3fc716420c

+ 4 - 2
vrobbler/apps/books/admin.py

@@ -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,
     ]

+ 3 - 2
vrobbler/apps/books/koreader.py

@@ -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",
                 ]

+ 12 - 1
vrobbler/apps/books/models.py

@@ -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")
+        )

+ 5 - 1
vrobbler/apps/books/openlibrary.py

@@ -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),
     }

+ 28 - 0
vrobbler/apps/books/utils.py

@@ -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

+ 4 - 2
vrobbler/apps/music/admin.py

@@ -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)

+ 14 - 2
vrobbler/apps/scrobbles/admin.py

@@ -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):

+ 2 - 0
vrobbler/apps/scrobbles/constants.py

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

+ 1 - 1
vrobbler/apps/scrobbles/forms.py

@@ -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",
             }
         ),

+ 39 - 1
vrobbler/apps/scrobbles/models.py

@@ -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:

+ 27 - 7
vrobbler/apps/scrobbles/scrobblers.py

@@ -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)
 
+    scrobble_dict = {
+        "user_id": user_id,
+        "timestamp": timezone.now(),
+        "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(
-        video_game=game, user_id=user_id, played_to_completion=True
+        book=book,
+        user_id=user_id,
+        played_to_completion=True,
+        long_play_complete=False,
     ).last()
 
-    playback_position = 0
-    if last_scrobble and not last_scrobble.playback_position:
-        playback_position = last_scrobble.playback_position + (30 * 60)
+    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": playback_position * 1000,
-        "playback_position": playback_position,
+        "playback_position_ticks": int(start_playback_position) * 1000,
+        "playback_position": start_playback_position,
         "source": "Vrobbler",
+        "long_play_complete": False,
     }
 
-    return Scrobble.create_or_update(game, user_id, scrobble_dict)
+    return Scrobble.create_or_update(book, user_id, scrobble_dict)

+ 17 - 8
vrobbler/apps/scrobbles/views.py

@@ -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 "-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(data_dict, self.request.user.id)
+                manual_scrobble_video_game(data_dict, self.request.user.id)
 
-        if not data_dict:
+        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"))
 

+ 7 - 3
vrobbler/apps/videogames/admin.py

@@ -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,

+ 19 - 1
vrobbler/apps/videogames/igdb.py

@@ -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

+ 15 - 4
vrobbler/apps/videogames/models.py

@@ -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:

+ 9 - 2
vrobbler/apps/videogames/urls.py

@@ -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",
+    ),
 ]

+ 9 - 5
vrobbler/apps/videogames/utils.py

@@ -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")

+ 6 - 1
vrobbler/apps/videogames/views.py

@@ -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"

+ 2 - 2
vrobbler/settings.py

@@ -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},

+ 2 - 0
vrobbler/urls.py

@@ -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")),