Add ability to manage long plays

This commit is contained in:
2023-03-11 14:11:31 -05:00
parent 6e03cf5075
commit bb2a80e2aa
9 changed files with 179 additions and 38 deletions

View File

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

View File

@ -1,9 +1,11 @@
import logging import logging
from typing import Dict from typing import Optional
from uuid import uuid4 from uuid import uuid4
from django.db import models from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from scrobbles.utils import get_scrobbles_for_media
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -27,3 +29,19 @@ class ScrobblableMixin(TimeStampedModel):
@classmethod @classmethod
def find_or_create(cls): def find_or_create(cls):
logger.warn("find_or_create() not implemented yet") 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()
)

View File

@ -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]): def manual_scrobble_book(data_dict: dict, user_id: Optional[int]):
book = Book.find_or_create(data_dict) 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 = { scrobble_dict = {
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"playback_position_ticks": int(start_playback_position) * 1000, "playback_position_ticks": None,
"playback_position": start_playback_position, "playback_position": 0,
"source": "Vrobbler", "source": "Vrobbler",
"long_play_complete": False, "long_play_complete": False,
} }

View File

@ -19,8 +19,14 @@ urlpatterns = [
views.KoReaderImportCreateView.as_view(), views.KoReaderImportCreateView.as_view(),
name="koreader-file-upload", name="koreader-file-upload",
), ),
path("finish/<slug:uuid>", views.scrobble_finish, name="finish"), path("start/<slug:uuid>/", views.scrobble_start, name="start"),
path("cancel/<slug:uuid>", views.scrobble_cancel, name="cancel"), 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( path(
"upload/", "upload/",
views.AudioScrobblerImportCreateView.as_view(), views.AudioScrobblerImportCreateView.as_view(),

View File

@ -4,6 +4,7 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz import pytz
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -31,6 +32,7 @@ from scrobbles.api import serializers
from scrobbles.constants import ( from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES, JELLYFIN_AUDIO_ITEM_TYPES,
JELLYFIN_VIDEO_ITEM_TYPES, JELLYFIN_VIDEO_ITEM_TYPES,
LONG_PLAY_MEDIA,
) )
from scrobbles.export import export_scrobbles from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm 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 videogames.howlongtobeat import lookup_game_from_hltb
from videos.imdb import lookup_video_from_imdb from videos.imdb import lookup_video_from_imdb
from vrobbler.apps.books.openlibrary import lookup_book_from_openlibrary from books.openlibrary import lookup_book_from_openlibrary
from vrobbler.apps.scrobbles.utils import ( from scrobbles.utils import (
get_long_plays_completed, get_long_plays_completed,
get_long_plays_in_progress, 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]) @permission_classes([IsAuthenticated])
@api_view(["GET"]) @api_view(["GET"])
def scrobble_finish(request, uuid): def scrobble_finish(request, uuid):

View File

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

View File

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

View File

@ -2,6 +2,15 @@
{% block title %}Long Plays{% endblock %} {% 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 %} {% block lists %}
<div class="row"> <div class="row">
@ -10,14 +19,22 @@
<div> <div>
{% for media in in_progress %} {% for media in in_progress %}
{% if media.hltb_cover %} {% 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> <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> </dl>
{% elif media.cover %} {% 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> <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> </dl>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -45,17 +62,18 @@
</div> </div>
{% endif %} {% endif %}
<hr/>
<h2>Completed</h2> <h2>Completed</h2>
{% if view == 'grid' %} {% if view == 'grid' %}
<div> <div>
{% for media in completed %} {% for media in completed %}
{% if media.hltb_cover %} {% 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> <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> <dt><a href="{{media.get_absolute_url}}">{{media.title}}</a></dt>
</dl> </dl>
{% elif media.cover %} {% 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> <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> <dt><a href="{{media.get_absolute_url}}">{{media.title}}</a></dt>
</dl> </dl>
@ -85,4 +103,4 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

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