瀏覽代碼

Add ability to manage long plays

Colin Powell 2 年之前
父節點
當前提交
bb2a80e2aa

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

@@ -12,7 +12,7 @@ from django.core.files.base import ContentFile
 from django.db import models
 from django.urls import reverse
 from django_extensions.db.models import TimeStampedModel
-from scrobbles.mixins import ScrobblableMixin
+from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableMixin
 from scrobbles.utils import get_scrobbles_for_media
 
 logger = logging.getLogger(__name__)
@@ -55,7 +55,7 @@ class Author(TimeStampedModel):
                 self.headshot.save(fname, ContentFile(r.content), save=True)
 
 
-class Book(ScrobblableMixin):
+class Book(LongPlayScrobblableMixin):
     COMPLETION_PERCENT = getattr(settings, "BOOK_COMPLETION_PERCENT", 95)
     AVG_PAGE_READING_SECONDS = getattr(
         settings, "AVERAGE_PAGE_READING_SECONDS", 60
@@ -78,6 +78,16 @@ class Book(ScrobblableMixin):
     def __str__(self):
         return f"{self.title} by {self.author}"
 
+    @property
+    def subtitle(self):
+        return f" by {self.author}"
+
+    def get_start_url(self):
+        return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
+
+    def get_absolute_url(self):
+        return reverse("books:book_detail", kwargs={"slug": self.uuid})
+
     def fix_metadata(self, force_update=False):
         if not self.openlibrary_id or force_update:
             book_dict = lookup_book_from_openlibrary(self.title, self.author)
@@ -130,9 +140,6 @@ class Book(ScrobblableMixin):
     def author(self):
         return self.authors.first()
 
-    def get_absolute_url(self):
-        return reverse("books:book_detail", kwargs={"slug": self.uuid})
-
     @property
     def pages_for_completion(self) -> int:
         if not self.pages:

+ 19 - 1
vrobbler/apps/scrobbles/mixins.py

@@ -1,9 +1,11 @@
 import logging
-from typing import Dict
+from typing import Optional
 from uuid import uuid4
 
 from django.db import models
+from django.urls import reverse
 from django_extensions.db.models import TimeStampedModel
+from scrobbles.utils import get_scrobbles_for_media
 
 BNULL = {"blank": True, "null": True}
 
@@ -27,3 +29,19 @@ class ScrobblableMixin(TimeStampedModel):
     @classmethod
     def find_or_create(cls):
         logger.warn("find_or_create() not implemented yet")
+
+
+class LongPlayScrobblableMixin(ScrobblableMixin):
+    class Meta:
+        abstract = True
+
+    def get_longplay_finish_url(self):
+        return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})
+
+    def last_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
+        return (
+            get_scrobbles_for_media(self, user)
+            .filter(long_play_complete=False)
+            .order_by("-timestamp")
+            .first()
+        )

+ 2 - 12
vrobbler/apps/scrobbles/scrobblers.py

@@ -222,21 +222,11 @@ def manual_scrobble_video_game(data_dict: dict, user_id: Optional[int]):
 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,
+        "playback_position_ticks": None,
+        "playback_position": 0,
         "source": "Vrobbler",
         "long_play_complete": False,
     }

+ 8 - 2
vrobbler/apps/scrobbles/urls.py

@@ -19,8 +19,14 @@ urlpatterns = [
         views.KoReaderImportCreateView.as_view(),
         name="koreader-file-upload",
     ),
-    path("finish/<slug:uuid>", views.scrobble_finish, name="finish"),
-    path("cancel/<slug:uuid>", views.scrobble_cancel, name="cancel"),
+    path("start/<slug:uuid>/", views.scrobble_start, name="start"),
+    path("finish/<slug:uuid>/", views.scrobble_finish, name="finish"),
+    path("cancel/<slug:uuid>/", views.scrobble_cancel, name="cancel"),
+    path(
+        "long-play-finish/<slug:uuid>/",
+        views.scrobble_longplay_finish,
+        name="longplay-finish",
+    ),
     path(
         "upload/",
         views.AudioScrobblerImportCreateView.as_view(),

+ 82 - 2
vrobbler/apps/scrobbles/views.py

@@ -4,6 +4,7 @@ import logging
 from datetime import datetime, timedelta
 
 import pytz
+from django.apps import apps
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.mixins import LoginRequiredMixin
@@ -31,6 +32,7 @@ from scrobbles.api import serializers
 from scrobbles.constants import (
     JELLYFIN_AUDIO_ITEM_TYPES,
     JELLYFIN_VIDEO_ITEM_TYPES,
+    LONG_PLAY_MEDIA,
 )
 from scrobbles.export import export_scrobbles
 from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
@@ -60,8 +62,8 @@ from sports.thesportsdb import lookup_event_from_thesportsdb
 from videogames.howlongtobeat import lookup_game_from_hltb
 from videos.imdb import lookup_video_from_imdb
 
-from vrobbler.apps.books.openlibrary import lookup_book_from_openlibrary
-from vrobbler.apps.scrobbles.utils import (
+from books.openlibrary import lookup_book_from_openlibrary
+from scrobbles.utils import (
     get_long_plays_completed,
     get_long_plays_in_progress,
 )
@@ -388,6 +390,84 @@ def import_audioscrobbler_file(request):
         )
 
 
+@permission_classes([IsAuthenticated])
+@api_view(["GET"])
+def scrobble_start(request, uuid):
+    user = request.user
+    success_url = request.META.get("HTTP_REFERER")
+
+    if not user.is_authenticated:
+        return HttpResponseRedirect(success_url)
+
+    media_obj = None
+    for app, model in LONG_PLAY_MEDIA.items():
+        media_model = apps.get_model(app_label=app, model_name=model)
+        media_obj = media_model.objects.filter(uuid=uuid).first()
+        if media_obj:
+            break
+
+    if not media_obj:
+        return
+
+    scrobble = None
+    user_id = request.user.id
+    if media_obj and media_obj.__class__.__name__ == "Book":
+        data_dict = {
+            "title": media_obj.title,
+            "author": media_obj.author.name,
+        }
+        scrobble = manual_scrobble_book(data_dict, user_id)
+    if media_obj and media_obj.__class__.__name__ == "VideoGame":
+        data_dict = {"hltb_id": media_obj.hltb_id}
+        scrobble = manual_scrobble_video_game(data_dict, user_id)
+
+    if scrobble:
+        messages.add_message(
+            request,
+            messages.SUCCESS,
+            f"Scrobble of {scrobble.media_obj} started.",
+        )
+    else:
+        messages.add_message(
+            request, messages.ERROR, f"Media with uuid {uuid} not found."
+        )
+    return HttpResponseRedirect(success_url)
+
+
+@api_view(["GET"])
+def scrobble_longplay_finish(request, uuid):
+    user = request.user
+    success_url = request.META.get("HTTP_REFERER")
+
+    if not user.is_authenticated:
+        return HttpResponseRedirect(success_url)
+
+    media_obj = None
+    for app, model in LONG_PLAY_MEDIA.items():
+        media_model = apps.get_model(app_label=app, model_name=model)
+        media_obj = media_model.objects.filter(uuid=uuid).first()
+        if media_obj:
+            break
+
+    if not media_obj:
+        return
+
+    last_scrobble = media_obj.last_long_play_scrobble_for_user(user)
+    if last_scrobble and last_scrobble.long_play_complete == False:
+        last_scrobble.long_play_complete = True
+        last_scrobble.save(update_fields=["long_play_complete"])
+        messages.add_message(
+            request,
+            messages.SUCCESS,
+            f"Long play of {media_obj} finished.",
+        )
+    else:
+        messages.add_message(
+            request, messages.ERROR, f"Media with uuid {uuid} not found."
+        )
+    return HttpResponseRedirect(success_url)
+
+
 @permission_classes([IsAuthenticated])
 @api_view(["GET"])
 def scrobble_finish(request, uuid):

+ 9 - 3
vrobbler/apps/videogames/models.py

@@ -1,5 +1,4 @@
 import logging
-from typing import Optional
 from uuid import uuid4
 
 from django.conf import settings
@@ -7,7 +6,7 @@ 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.mixins import ScrobblableMixin
+from scrobbles.mixins import LongPlayScrobblableMixin
 from scrobbles.utils import get_scrobbles_for_media
 from videogames.igdb import lookup_game_id_from_gdb
 
@@ -46,7 +45,7 @@ class VideoGameCollection(TimeStampedModel):
         )
 
 
-class VideoGame(ScrobblableMixin):
+class VideoGame(LongPlayScrobblableMixin):
     COMPLETION_PERCENT = getattr(settings, "GAME_COMPLETION_PERCENT", 100)
 
     FIELDS_FROM_IGDB = [
@@ -89,6 +88,10 @@ class VideoGame(ScrobblableMixin):
     def __str__(self):
         return self.title
 
+    @property
+    def subtitle(self):
+        return f" On {self.platforms.first()}"
+
     def get_absolute_url(self):
         return reverse(
             "videogames:videogame_detail", kwargs={"slug": self.uuid}
@@ -101,6 +104,9 @@ class VideoGame(ScrobblableMixin):
         slug = self.title.lower().replace(" ", "-")
         return f"https://igdb.com/games/{slug}"
 
+    def get_start_url(self):
+        return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
+
     def save(self, **kwargs):
         super().save(**kwargs)
         self.fix_metadata()

+ 10 - 2
vrobbler/templates/base.html

@@ -31,11 +31,13 @@
             min-height: 3em;
             border-right: 1px solid #777;
         }
+        .now-playing { margin-right:20px;}
         .now-playing p { margin:0; }
         .now-playing .right { float:right; margin-right:10px; }
         .latest-scrobble {
             width: 50%;
         }
+        .now-playing img { height:75px; width: 75px; object-fit: cover; }
 
         .progress-bar {
             width: 100%;
@@ -244,13 +246,18 @@
                             {% for scrobble in now_playing_list %}
                             <div class="now-playing">
                                 {% if scrobble.media_obj.album.cover_image %}
-                                <div style="float:left;padding-right:5px;"><img src="{{scrobble.track.album.cover_image.url}}" width=75 height=75 style="border:1px solid black; " /></div>
+                                <div style="float:left;padding-right:5px;"><img src="{{scrobble.media_obj.album.cover_image.url}}" /></div>
+                                {% endif %}
+                                {% if scrobble.media_obj.cover %}
+                                <div style="float:left;padding-right:5px;"><img src="{{scrobble.media_obj.cover.url}}" /></div>
+                                {% endif %}
+                                {% if scrobble.media_obj.hltb_cover %}
+                                <div style="float:left;padding-right:5px;"><img src="{{scrobble.media_obj.hltb_cover.url}}" /></div>
                                 {% endif %}
                                 <p><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></p>
                                 {% if scrobble.media_obj.subtitle %}
                                 <p><em><a href="{{scrobble.media_obj.subtitle.get_absolute_url}}">{{scrobble.media_obj.subtitle}}</a></em></p>
                                 {% endif %}
-                                <br/>
                                 <p><small>{{scrobble.timestamp|naturaltime}} from {{scrobble.source}}</small></p>
                                 <div class="progress-bar" style="margin-right:5px;">
                                     <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
@@ -259,6 +266,7 @@
                                     <a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
                                     <a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
                                 </p>
+                                {% if not forloop.last %}<hr/>{% endif %}
                             </div>
                             {% endfor %}
                         </ul>

+ 25 - 7
vrobbler/templates/scrobbles/long_plays_in_progress.html

@@ -2,6 +2,15 @@
 
 {% block title %}Long Plays{% endblock %}
 
+{% block head_extra %}
+<style>
+ dl { width: 260px; float:left; margin-right: 10px; }
+ dt a { color:white; text-decoration: none; font-size:smaller; }
+ img { height:200px; width: 250px; object-fit: cover; }
+ dd .right { float:right; }
+</style>
+{% endblock  %}
+
 {% block lists %}
 <div class="row">
 
@@ -10,14 +19,22 @@
     <div>
         {% for media in in_progress %}
         {% if media.hltb_cover %}
-        <dl style="width: 210px; float: left; margin-right:10px;">
-            <dd><a href="{{media.get_absolute_url}}"><img src="{{media.hltb_cover.url}}" width=200 /></a></dd>
+        <dl>
             <dt><a href="{{media.get_absolute_url}}">{{media.title}}</a></dt>
+            <dd><a href="{{media.get_absolute_url}}"><img src="{{media.hltb_cover.url}}" width=200 height=200 /></a></dd>
+            <dd>
+                <a type="button" class="btn btn-sm btn-primary" href="{{media.get_start_url}}">Resume</a>
+                <a type="button" class="right btn btn-sm " href="{{media.get_longplay_finish_url}}">Finish</a>
+            </dd>
         </dl>
         {% elif media.cover %}
-        <dl style="width: 210px; float: left; margin-right:10px;">
-            <dd><a href="{{media.get_absolute_url}}"><img src="{{media.cover.url}}" width=200 /></a></dd>
+        <dl>
             <dt><a href="{{media.get_absolute_url}}">{{media.title}}</a></dt>
+            <dd><a href="{{media.get_absolute_url}}"><img src="{{media.cover.url}}" style="width: 200px; height: 200px; object-fit:cover; " /></a></dd>
+            <dd>
+                <a type="button" class="btn btn-sm btn-primary" href="{{media.get_start_url}}">Resume</a>
+                <a type="button" class="right btn btn-sm " href="{{media.get_longplay_finish_url}}">Finish</a>
+            </dd>
         </dl>
         {% endif %}
         {% endfor %}
@@ -45,17 +62,18 @@
     </div>
     {% endif %}
 
+    <hr/>
     <h2>Completed</h2>
     {% if view == 'grid' %}
     <div>
         {% for media in completed %}
         {% if media.hltb_cover %}
-        <dl style="width: 210px; float: left; margin-right:10px;">
+        <dl>
             <dd><a href="{{media.get_absolute_url}}"><img src="{{media.hltb_cover.url}}" width=200 /></a></dd>
             <dt><a href="{{media.get_absolute_url}}">{{media.title}}</a></dt>
         </dl>
         {% elif media.cover %}
-        <dl style="width: 210px; float: left; margin-right:10px;">
+        <dl>
             <dd><a href="{{media.get_absolute_url}}"><img src="{{media.cover.url}}" width=200 /></a></dd>
             <dt><a href="{{media.get_absolute_url}}">{{media.title}}</a></dt>
         </dl>
@@ -85,4 +103,4 @@
     </div>
     {% endif %}
 </div>
-{% endblock %}
+{% endblock %}

+ 12 - 4
vrobbler/templates/videogames/videogame_detail.html

@@ -5,16 +5,24 @@
 
 {% block title %}{{object.title}}{% endblock %}
 
+{% block head_extra %}
+<style>
+.cover img { width: 250px; }
+.cover { float: left; width:252px; padding:0; border: 1px solid #ccc; }
+.summary {
+     float:left; width:600px; margin-left:10px;
+ }
+</style>
+{% endblock  %}
+
 {% block lists %}
 
 <div class="row">
 
     {% if object.hltb_cover%}
-    <p style="float:left; width:202px; padding:0; border: 1px solid #ccc">
-        <img src="{{object.hltb_cover.url}}" width=200 />
-    </p>
+    <div class="cover"><img src="{{object.hltb_cover.url}}" /></div>
     {% endif %}
-    <div style="float:left; width:600px; margin-left:10px; ">
+    <div class="summary">
         {% if object.summary %}
         <p>{{object.summary|safe|linebreaks|truncatewords:160}}</p>
         <hr/>