Add rudimentary manual scrobbling
This commit is contained in:
5
vrobbler/apps/scrobbles/forms.py
Normal file
5
vrobbler/apps/scrobbles/forms.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class ScrobbleForm(forms.Form):
|
||||
imdb_id = forms.CharField(label="IMDB", max_length=30)
|
||||
57
vrobbler/apps/scrobbles/imdb.py
Normal file
57
vrobbler/apps/scrobbles/imdb.py
Normal file
@ -0,0 +1,57 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from django.utils import timezone
|
||||
|
||||
import imdb
|
||||
from videos.models import Video
|
||||
|
||||
imdb_client = imdb.Cinemagoer()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def lookup_video_from_imdb(imdb_id: str) -> dict:
|
||||
|
||||
if 'tt' not in imdb_id:
|
||||
logger.warning(f"IMDB ID should begin with 'tt' {imdb_id}")
|
||||
return
|
||||
|
||||
lookup_id = imdb_id.strip('tt')
|
||||
media = imdb_client.get_movie(lookup_id)
|
||||
|
||||
run_time_seconds = int(media.get('runtimes')[0]) * 60
|
||||
# Ticks otherwise known as miliseconds
|
||||
run_time_ticks = run_time_seconds * 1000 * 1000
|
||||
|
||||
item_type = Video.VideoType.MOVIE
|
||||
if media.get('series title'):
|
||||
item_type = Video.VideoType.TV_EPISODE
|
||||
|
||||
try:
|
||||
plot = media.get('plot')[0]
|
||||
except TypeError:
|
||||
plot = ""
|
||||
except IndexError:
|
||||
plot = ""
|
||||
|
||||
# Build a rough approximation of a Jellyfin data response
|
||||
data_dict = {
|
||||
"ItemType": item_type,
|
||||
"Name": media.get('title'),
|
||||
"Overview": plot,
|
||||
"Tagline": media.get('tagline'),
|
||||
"Year": media.get('year'),
|
||||
"Provider_imdb": imdb_id,
|
||||
"RunTime": run_time_seconds,
|
||||
"RunTimeTicks": run_time_ticks,
|
||||
"SeriesName": media.get('series title'),
|
||||
"EpisodeNumber": media.get('episode'),
|
||||
"SeasonNumber": media.get('season'),
|
||||
"PlaybackPositionTicks": 1,
|
||||
"PlaybackPosition": 1,
|
||||
"UtcTimestamp": timezone.now().strftime('%Y-%m-%d %H:%M:%S.%f%z'),
|
||||
"IsPaused": False,
|
||||
"PlayedToCompletion": False,
|
||||
}
|
||||
|
||||
return data_dict
|
||||
@ -42,24 +42,27 @@ class Scrobble(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def percent_played(self) -> int:
|
||||
if (
|
||||
self.playback_position_ticks
|
||||
and self.media_obj.run_time_ticks
|
||||
and self.source != 'Mopidy'
|
||||
):
|
||||
return int(
|
||||
(self.playback_position_ticks / self.media_obj.run_time_ticks)
|
||||
* 100
|
||||
)
|
||||
# If we don't have media_obj.run_time_ticks, let's guess from created time
|
||||
now = timezone.now()
|
||||
playback_duration = (now - self.created).seconds
|
||||
if playback_duration and self.media_obj.run_time:
|
||||
return int(
|
||||
(playback_duration / int(self.media_obj.run_time)) * 100
|
||||
)
|
||||
playback_ticks = None
|
||||
percent_played = 100
|
||||
|
||||
return 0
|
||||
if not self.media_obj.run_time_ticks:
|
||||
logger.warning(
|
||||
f"{self} has no run_time_ticks value, cannot show percent played"
|
||||
)
|
||||
return percent_played
|
||||
|
||||
playback_ticks = self.playback_position_ticks
|
||||
if not playback_ticks:
|
||||
logger.info(
|
||||
"No playback_position_ticks, estimating based on creation time"
|
||||
)
|
||||
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
|
||||
|
||||
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
|
||||
|
||||
if percent > 100:
|
||||
percent = 100
|
||||
return percent
|
||||
|
||||
@property
|
||||
def media_obj(self):
|
||||
|
||||
@ -104,11 +104,15 @@ def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
|
||||
if data_dict.get("PlayedToCompletion"):
|
||||
jellyfin_status = "stopped"
|
||||
|
||||
playback_position_ticks = data_dict.get("PlaybackPositionTicks") // 10000
|
||||
if playback_position_ticks <= 0:
|
||||
playback_position_ticks = None
|
||||
|
||||
logger.debug(playback_position_ticks)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"timestamp": parse(data_dict.get("UtcTimestamp")),
|
||||
"playback_position_ticks": data_dict.get("PlaybackPositionTicks")
|
||||
// 10000,
|
||||
"playback_position_ticks": playback_position_ticks,
|
||||
"playback_position": convert_to_seconds(
|
||||
data_dict.get("PlaybackPosition")
|
||||
),
|
||||
@ -176,3 +180,16 @@ def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
||||
|
||||
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
||||
if not data_dict.get("Provider_imdb", None):
|
||||
logger.error(
|
||||
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
return
|
||||
video = Video.find_or_create(data_dict)
|
||||
|
||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
||||
|
||||
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
|
||||
|
||||
@ -10,8 +10,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def convert_to_seconds(run_time: str) -> int:
|
||||
"""Jellyfin sends run time as 00:00:00 string. We want the run time to
|
||||
actually be in seconds so we'll convert it"""
|
||||
if ":" in run_time:
|
||||
actually be in seconds so we'll convert it"
|
||||
|
||||
This is actually deprecated, as we now convert to seconds before saving.
|
||||
But for older videos, we'll leave this here.
|
||||
"""
|
||||
if ":" in str(run_time):
|
||||
run_time_list = run_time.split(":")
|
||||
hours = int(run_time_list[0])
|
||||
minutes = int(run_time_list[1])
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from dateutil.parser import parse
|
||||
from django.conf import settings
|
||||
from django.db.models.fields import timezone
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.list import ListView
|
||||
from music.constants import JELLYFIN_POST_KEYS as KEYS
|
||||
from music.models import Track
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
@ -17,16 +16,17 @@ from scrobbles.constants import (
|
||||
JELLYFIN_AUDIO_ITEM_TYPES,
|
||||
JELLYFIN_VIDEO_ITEM_TYPES,
|
||||
)
|
||||
from scrobbles.forms import ScrobbleForm
|
||||
from scrobbles.imdb import lookup_video_from_imdb
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.scrobblers import (
|
||||
jellyfin_scrobble_track,
|
||||
jellyfin_scrobble_video,
|
||||
manual_scrobble_video,
|
||||
mopidy_scrobble_podcast,
|
||||
mopidy_scrobble_track,
|
||||
)
|
||||
from scrobbles.serializers import ScrobbleSerializer
|
||||
from scrobbles.utils import convert_to_seconds
|
||||
from videos.models import Video
|
||||
|
||||
from vrobbler.apps.music.aggregators import (
|
||||
scrobble_counts,
|
||||
@ -85,6 +85,20 @@ class RecentScrobbleList(ListView):
|
||||
).order_by('-timestamp')[:15]
|
||||
|
||||
|
||||
class ManualScrobbleView(FormView):
|
||||
form_class = ScrobbleForm
|
||||
template_name = 'scrobbles/manual_form.html'
|
||||
|
||||
def form_valid(self, form):
|
||||
|
||||
# look for video via IMDB id
|
||||
data_dict = lookup_video_from_imdb(form.cleaned_data.get('imdb_id'))
|
||||
|
||||
manual_scrobble_video(data_dict, self.request.user.id)
|
||||
|
||||
return HttpResponseRedirect(reverse("home"))
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['GET'])
|
||||
def scrobble_endpoint(request):
|
||||
|
||||
@ -96,11 +96,16 @@ class Video(ScrobblableMixin):
|
||||
|
||||
video, created = cls.objects.get_or_create(**video_dict)
|
||||
|
||||
logger.debug(data_dict)
|
||||
run_time_ticks = data_dict.get("RunTimeTicks", None)
|
||||
if run_time_ticks:
|
||||
run_time_ticks = run_time_ticks // 10000
|
||||
|
||||
video_extra_dict = {
|
||||
"year": data_dict.get("Year", ""),
|
||||
"overview": data_dict.get("Overview", None),
|
||||
"tagline": data_dict.get("Tagline", None),
|
||||
"run_time_ticks": data_dict.get("RunTimeTicks", 0) // 10000,
|
||||
"run_time_ticks": run_time_ticks,
|
||||
"run_time": convert_to_seconds(data_dict.get("RunTime", "")),
|
||||
"tvdb_id": data_dict.get("Provider_tvdb", None),
|
||||
"tvrage_id": data_dict.get("Provider_tvrage", None),
|
||||
|
||||
@ -62,6 +62,8 @@ TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "EST")
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")
|
||||
|
||||
@ -199,7 +199,7 @@
|
||||
{% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %}
|
||||
{% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %}
|
||||
{% if scrobble.video.tv_series %}<em>{{scrobble.video.tv_series }}</em><br/>{% endif %}
|
||||
<small>{{scrobble.created|naturaltime}}<br/>
|
||||
<small>{{scrobble.timestamp|naturaltime}}<br/>
|
||||
from {{scrobble.source}}</small>
|
||||
<div class="progress-bar" style="margin-right:5px;">
|
||||
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
||||
@ -243,6 +243,12 @@
|
||||
</a>
|
||||
</li>
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/manual/">
|
||||
<span data-feather="key"></span>
|
||||
Manual scrobble
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/">
|
||||
<span data-feather="key"></span>
|
||||
|
||||
13
vrobbler/templates/scrobbles/manual_form.html
Normal file
13
vrobbler/templates/scrobbles/manual_form.html
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Manual scrobble</h1>
|
||||
<form action="#" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@ -3,7 +3,7 @@ from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from rest_framework import routers
|
||||
from scrobbles.views import RecentScrobbleList
|
||||
from scrobbles.views import RecentScrobbleList, ManualScrobbleView
|
||||
from videos import urls as video_urls
|
||||
|
||||
from scrobbles import urls as scrobble_urls
|
||||
@ -15,6 +15,7 @@ urlpatterns = [
|
||||
# path("movies/", include(movies, namespace="movies")),
|
||||
# path("shows/", include(shows, namespace="shows")),
|
||||
path("api/v1/scrobbles/", include(scrobble_urls, namespace="scrobbles")),
|
||||
path('manual/', ManualScrobbleView.as_view(), name='manual-scrobble'),
|
||||
path("", include(video_urls, namespace="videos")),
|
||||
path("", RecentScrobbleList.as_view(), name="home"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user