Compare commits

...

9 Commits
0.1.4 ... 0.1.7

Author SHA1 Message Date
c3b48099bf Bump version to 0.1.7 2023-01-08 02:52:12 -05:00
1476fe37ca Auth is jamming up the works right now 2023-01-08 02:51:35 -05:00
842378e812 Need to load JSON data 2023-01-08 02:50:54 -05:00
07ad6005c8 Add rudimentary support for mopidy-webhooks 2023-01-08 00:26:24 -05:00
638be0b56a Refactor scrobbling code and add Music
If you send Track data from the Jellyfin Webhook plugin, we'll do the
right thing with it. Lots more to do to clean this up, but it also
involved moduralizing the code for scrobbling so it's a little simpler
to understand what's going on.
2023-01-07 19:34:11 -05:00
9c0d85c5d2 Bump version to 0.1.6 2023-01-06 16:26:22 -05:00
1b58ea4538 Fix max-height bug on dl for in progress scrobble 2023-01-06 16:24:00 -05:00
b318bc0da9 Bump version to 0.1.5 2023-01-06 16:19:06 -05:00
fe39118b55 Add a progress bar to in progress scrobble 2023-01-06 16:17:42 -05:00
20 changed files with 774 additions and 149 deletions

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "0.1.4"
version = "0.1.7"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

25
templates/base.html Normal file
View File

@ -0,0 +1,25 @@
<!doctype html>
<html class="no-js" lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Untitled</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Place favicon.ico in the root directory -->
</head>
<body>
<!--[if lt IE 8]>
<p class="browserupgrade">
You are using an <strong>outdated</strong> browser. Please
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
your experience.
</p>
<![endif]-->
</body>
</html>

View File

View File

@ -0,0 +1,29 @@
from django.contrib import admin
from music.models import Artist, Album, Track
@admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "year", "musicbrainz_id")
list_filter = ("year",)
ordering = ("name",)
@admin.register(Artist)
class ArtistAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "musicbrainz_id")
ordering = ("name",)
@admin.register(Track)
class TrackAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"album",
"artist",
"run_time",
"musicbrainz_id",
)
list_filter = ("album", "artist")
ordering = ("-created",)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class MusicConfig(AppConfig):
name = 'music'

View File

@ -0,0 +1,16 @@
JELLYFIN_POST_KEYS = {
'ITEM_TYPE': 'ItemType',
'RUN_TIME_TICKS': 'RunTimeTicks',
'RUN_TIME': 'RunTime',
'TITLE': 'Name',
'TIMESTAMP': 'UtcTimestamp',
'YEAR': 'Year',
'PLAYBACK_POSITION_TICKS': 'PlaybackPositionTicks',
'PLAYBACK_POSITION': 'PlaybackPosition',
'ARTIST_MB_ID': 'Provider_musicbrainzartist',
'ALBUM_MB_ID': 'Provider_musicbrainzalbum',
'RELEASEGROUP_MB_ID': 'Provider_musicbrainzreleasegroup',
'TRACK_MB_ID': 'Provider_musicbrainztrack',
'ALBUM_NAME': 'Album',
'ARTIST_NAME': 'Artist',
}

View File

@ -0,0 +1,156 @@
# Generated by Django 4.1.5 on 2023-01-07 19:37
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='Album',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
('name', models.CharField(max_length=255)),
('year', models.IntegerField()),
(
'musicbrainz_id',
models.CharField(blank=True, max_length=255, null=True),
),
(
'musicbrainz_releasegroup_id',
models.CharField(blank=True, max_length=255, null=True),
),
(
'musicbrainz_albumartist_id',
models.CharField(blank=True, max_length=255, null=True),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.CreateModel(
name='Artist',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
('name', models.CharField(max_length=255)),
(
'musicbrainz_id',
models.CharField(blank=True, max_length=255, null=True),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.CreateModel(
name='Track',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
(
'title',
models.CharField(blank=True, max_length=255, null=True),
),
(
'musicbrainz_id',
models.CharField(blank=True, max_length=255, null=True),
),
(
'run_time',
models.CharField(blank=True, max_length=8, null=True),
),
(
'run_time_ticks',
models.PositiveBigIntegerField(blank=True, null=True),
),
(
'album',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='music.album',
),
),
(
'artist',
models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
to='music.artist',
),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-08 01:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='album',
name='year',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,79 @@
import logging
from typing import Dict, Optional
from django.db import models
from django_extensions.db.models import TimeStampedModel
from django.utils.translation import gettext_lazy as _
from vrobbler.apps.music.constants import JELLYFIN_POST_KEYS as KEYS
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
class Album(TimeStampedModel):
name = models.CharField(max_length=255)
year = models.IntegerField(**BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.name
class Artist(TimeStampedModel):
name = models.CharField(max_length=255)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.name
class Track(TimeStampedModel):
title = models.CharField(max_length=255, **BNULL)
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
run_time = models.CharField(max_length=8, **BNULL)
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
def __str__(self):
return f"{self.title} by {self.artist}"
@classmethod
def find_or_create(
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
) -> Optional["Track"]:
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
the video and, if need, TV Series, creating both if they don't yet
exist.
"""
if not artist_dict.get('name') or not artist_dict.get(
'musicbrainz_id'
):
logger.warning(
f"No artist or artist musicbrainz ID found in message from Jellyfin, not scrobbling"
)
return
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
if artist_created:
logger.debug(f"Created new album {artist}")
else:
logger.debug(f"Found album {artist}")
album, album_created = Album.objects.get_or_create(**album_dict)
if album_created:
logger.debug(f"Created new album {album}")
else:
logger.debug(f"Found album {album}")
track_dict['album_id'] = getattr(album, "id", None)
track_dict['artist_id'] = artist.id
track, created = cls.objects.get_or_create(**track_dict)
if created:
logger.debug(f"Created new track: {track}")
else:
logger.debug(f"Found track{track}")
return track

View File

@ -6,8 +6,9 @@ from scrobbles.models import Scrobble
class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp"
list_display = (
"video",
"timestamp",
"video",
"track",
"source",
"playback_position",
"in_progress",

View File

@ -0,0 +1,3 @@
#!/usr/bin/env python3
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.1.5 on 2023-01-07 20:03
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('music', '0001_initial'),
('videos', '0003_alter_video_run_time_ticks'),
('scrobbles', '0005_alter_scrobble_playback_position_ticks'),
]
operations = [
migrations.AddField(
model_name='scrobble',
name='track',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='music.track',
),
),
migrations.AlterField(
model_name='scrobble',
name='video',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='videos.video',
),
),
]

View File

@ -1,15 +1,27 @@
import logging
from datetime import timedelta
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from music.models import Track
from videos.models import Video
logger = logging.getLogger(__name__)
User = get_user_model()
BNULL = {"blank": True, "null": True}
VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES')
TRACK_BACKOFF = getattr(settings, 'MUSIC_BACKOFF_SECONDS')
VIDEO_WAIT_PERIOD = getattr(settings, 'VIDEO_WAIT_PERIOD_DAYS')
TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
class Scrobble(TimeStampedModel):
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING
)
@ -25,9 +37,158 @@ class Scrobble(TimeStampedModel):
@property
def percent_played(self) -> int:
return int(
(self.playback_position_ticks / self.video.run_time_ticks) * 100
)
if self.playback_position_ticks and self.media_run_time_ticks:
return int(
(self.playback_position_ticks / self.media_run_time_ticks)
* 100
)
return 0
@property
def media_run_time_ticks(self) -> int:
if self.video:
return self.video.run_time_ticks
if self.track:
return self.track.run_time_ticks
# this is hacky, but want to avoid divide by zero
return 1
def is_stale(self, backoff, wait_period) -> bool:
scrobble_is_stale = self.in_progress and self.modified > wait_period
# Check if found in progress scrobble is more than a day old
if scrobble_is_stale:
logger.info(
'Found a in-progress scrobble for this item more than a day old, creating a new scrobble'
)
delete_stale_scrobbles = getattr(
settings, "DELETE_STALE_SCROBBLES", True
)
if delete_stale_scrobbles:
logger.info(
'Deleting {scrobble} that has been in-progress too long'
)
self.delete()
return scrobble_is_stale
def __str__(self):
return f"Scrobble of {self.video} {self.timestamp.year}-{self.timestamp.month}"
media = None
if self.video:
media = self.video
if self.track:
media = self.track
return (
f"Scrobble of {media} {self.timestamp.year}-{self.timestamp.month}"
)
@classmethod
def create_or_update_for_video(
cls, video: "Video", user_id: int, jellyfin_data: dict
) -> "Scrobble":
jellyfin_data['video_id'] = video.id
logger.debug(
f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
)
scrobble = (
Scrobble.objects.filter(video=video, user_id=user_id)
.order_by('-modified')
.first()
)
# Backoff is how long until we consider this a new scrobble
backoff = timezone.now() + timedelta(minutes=VIDEO_BACKOFF)
wait_period = timezone.now() + timedelta(days=VIDEO_WAIT_PERIOD)
return cls.update_or_create(
scrobble, backoff, wait_period, jellyfin_data
)
@classmethod
def create_or_update_for_track(
cls, track: "Track", user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['track_id'] = track.id
scrobble = (
Scrobble.objects.filter(track=track, user_id=user_id)
.order_by('-modified')
.first()
)
logger.debug(
f"Found existing scrobble for track {track}, updating",
{"scrobble_data": scrobble_data},
)
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
return cls.update_or_create(
scrobble, backoff, wait_period, scrobble_data
)
@classmethod
def update_or_create(
cls,
scrobble: Optional["Scrobble"],
backoff,
wait_period,
scrobble_data: dict,
) -> Optional["Scrobble"]:
# Status is a field we get from Mopidy, which refuses to poll us
mopidy_status = scrobble_data.pop('status', None)
scrobble_is_stale = False
if mopidy_status == "stopped":
logger.info(f"Mopidy sent a message to stop {scrobble}")
if not scrobble:
logger.warning(
'Mopidy sent us a stopped message, without ever starting'
)
return
# Mopidy finished a play, scrobble away
scrobble.in_progress = False
scrobble.save(update_fields=['in_progress'])
return scrobble
if scrobble and not mopidy_status:
scrobble_is_finished = (
not scrobble.in_progress and scrobble.modified < backoff
)
if scrobble_is_finished:
logger.info(
'Found a very recent scrobble for this item, holding off scrobbling again'
)
return
scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
if (not scrobble or scrobble_is_stale) or mopidy_status:
# If we default this to "" we can probably remove this
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
else:
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
# If we hit our completion threshold, save it and get ready
# to scrobble again if we re-watch this.
if scrobble.percent_played >= getattr(
settings, "PERCENT_FOR_COMPLETION", 95
):
scrobble.in_progress = False
scrobble.playback_position_ticks = scrobble.media_run_time_ticks
scrobble.save()
if scrobble.percent_played % 5 == 0:
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
scrobble.save(update_fields=['scrobble_log'])
return scrobble

View File

@ -6,4 +6,5 @@ app_name = 'scrobbles'
urlpatterns = [
path('', views.scrobble_endpoint, name='scrobble-list'),
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
]

View File

@ -5,17 +5,22 @@ from datetime import datetime, timedelta
from dateutil.parser import parse
from django.conf import settings
from django.db.models.fields import timezone
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.list import ListView
from music.models import Track
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES,
JELLYFIN_VIDEO_ITEM_TYPES,
)
from scrobbles.models import Scrobble
from scrobbles.serializers import ScrobbleSerializer
from videos.models import Series, Video
from vrobbler.settings import DELETE_STALE_SCROBBLES
from django.utils import timezone
from videos.models import Video
from vrobbler.apps.music.constants import JELLYFIN_POST_KEYS as KEYS
logger = logging.getLogger(__name__)
@ -38,11 +43,11 @@ class RecentScrobbleList(ListView):
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
now = timezone.now()
last_ten_minutes = timezone.now() - timedelta(minutes=10)
last_three_minutes = timezone.now() - timedelta(minutes=3)
# Find scrobbles from the last 10 minutes
data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True,
timestamp__gte=last_ten_minutes,
timestamp__gte=last_three_minutes,
timestamp__lte=now,
)
return data
@ -66,122 +71,128 @@ def scrobble_endpoint(request):
@api_view(['POST'])
def jellyfin_websocket(request):
data_dict = request.data
media_type = data_dict["ItemType"]
imdb_id = data_dict.get("Provider_imdb", None)
if not imdb_id:
logger.error(
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return Response({}, status=status.HTTP_400_BAD_REQUEST)
# Check if it's a TV Episode
video_dict = {
"title": data_dict.get("Name", ""),
"imdb_id": imdb_id,
"video_type": Video.VideoType.MOVIE,
"year": data_dict.get("Year", ""),
}
if media_type == 'Episode':
series_name = data_dict["SeriesName"]
series, series_created = Series.objects.get_or_create(name=series_name)
video_dict['video_type'] = Video.VideoType.TV_EPISODE
video_dict["tv_series_id"] = series.id
video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
video_dict["season_number"] = data_dict.get("SeasonNumber", "")
video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None)
# For making things easier to build new input processors
if getattr(settings, "DUMP_REQUEST_DATA", False):
json_data = json.dumps(data_dict, indent=4)
logger.debug(f"{json_data}")
video, video_created = Video.objects.get_or_create(**video_dict)
media_type = data_dict.get("ItemType", "")
if video_created:
video.overview = data_dict["Overview"]
video.tagline = data_dict["Tagline"]
video.run_time_ticks = data_dict["RunTimeTicks"]
video.run_time = data_dict["RunTime"]
video.save()
track = None
video = None
if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
if not data_dict.get("Provider_musicbrainztrack", None):
logger.error(
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
)
return Response({}, status=status.HTTP_400_BAD_REQUEST)
artist_dict = {
'name': data_dict.get(KEYS["ARTIST_NAME"], None),
'musicbrainz_id': data_dict.get(KEYS["ARTIST_MB_ID"], None),
}
album_dict = {
"name": data_dict.get(KEYS["ALBUM_NAME"], None),
"year": data_dict.get(KEYS["YEAR"], ""),
"musicbrainz_id": data_dict.get(KEYS['ALBUM_MB_ID']),
"musicbrainz_releasegroup_id": data_dict.get(
KEYS["RELEASEGROUP_MB_ID"]
),
"musicbrainz_albumartist_id": data_dict.get(KEYS["ARTIST_MB_ID"]),
}
track_dict = {
"title": data_dict.get("Name", ""),
"musicbrainz_id": data_dict.get(KEYS["TRACK_MB_ID"], None),
"run_time_ticks": data_dict.get(KEYS["RUN_TIME_TICKS"], None),
"run_time": data_dict.get(KEYS["RUN_TIME"], None),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
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 Response({}, status=status.HTTP_400_BAD_REQUEST)
video = Video.find_or_create(data_dict)
# Now we run off a scrobble
timestamp = parse(data_dict["UtcTimestamp"])
scrobble_dict = {
'video_id': video.id,
'user_id': request.user.id,
'in_progress': True,
jellyfin_data = {
"user_id": request.user.id,
"timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": data_dict.get("PlaybackPositionTicks"),
"playback_position": data_dict.get("PlaybackPosition"),
"source": "Jellyfin",
"source_id": data_dict.get('MediaSourceId'),
"is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
}
existing_scrobble = (
Scrobble.objects.filter(video=video, user_id=request.user.id)
.order_by('-modified')
.first()
)
minutes_from_now = timezone.now() + timedelta(minutes=15)
a_day_from_now = timezone.now() + timedelta(days=1)
existing_finished_scrobble = (
existing_scrobble
and not existing_scrobble.in_progress
and existing_scrobble.modified < minutes_from_now
)
existing_in_progress_scrobble = (
existing_scrobble
and existing_scrobble.in_progress
and existing_scrobble.modified > a_day_from_now
)
delete_stale_scrobbles = getattr(settings, "DELETE_STALE_SCROBBLES", True)
if existing_finished_scrobble:
logger.info(
'Found a scrobble for this video less than 15 minutes ago, holding off scrobbling again'
scrobble = None
if video:
scrobble = Scrobble.create_or_update_for_video(
video, request.user.id, jellyfin_data
)
return Response(video_dict, status=status.HTTP_204_NO_CONTENT)
# Check if found in progress scrobble is more than a day old
if existing_in_progress_scrobble:
logger.info(
'Found a scrobble for this video more than a day old, creating a new scrobble'
)
scrobble = existing_in_progress_scrobble
scrobble_created = False
else:
if existing_in_progress_scrobble and delete_stale_scrobbles:
existing_in_progress_scrobble.delete()
scrobble, scrobble_created = Scrobble.objects.get_or_create(
**scrobble_dict
if track:
scrobble = Scrobble.create_or_update_for_track(
track, request.user.id, jellyfin_data
)
if scrobble_created:
# If we newly created this, capture the client we're watching from
scrobble.source = data_dict['ClientName']
scrobble.source_id = data_dict['MediaSourceId']
scrobble.scrobble_log = ""
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
# Update a found scrobble with new position and timestamp
scrobble.playback_position_ticks = data_dict["PlaybackPositionTicks"]
scrobble.playback_position = data_dict["PlaybackPosition"]
scrobble.timestamp = parse(data_dict["UtcTimestamp"])
scrobble.is_paused = data_dict["IsPaused"] in TRUTHY_VALUES
scrobble.save(
update_fields=[
'playback_position_ticks',
'playback_position',
'timestamp',
'is_paused',
]
return Response(
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
)
# If we hit our completion threshold, save it and get ready
# to scrobble again if we re-watch this.
if scrobble.percent_played >= getattr(
settings, "PERCENT_FOR_COMPLETION", 95
):
scrobble.in_progress = False
scrobble.playback_position_ticks = video.run_time_ticks
scrobble.save()
if scrobble.percent_played % 5 == 0:
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
scrobble.save(update_fields=['scrobble_log'])
logger.debug(f"You are {scrobble.percent_played}% through {video}")
@csrf_exempt
@api_view(['POST'])
def mopidy_websocket(request):
data_dict = json.loads(request.data)
return Response(video_dict, status=status.HTTP_201_CREATED)
# For making things easier to build new input processors
if getattr(settings, "DUMP_REQUEST_DATA", False):
json_data = json.dumps(data_dict, indent=4)
artist_dict = {
"name": data_dict.get("artist", None),
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
}
album_dict = {
"name": data_dict.get("album"),
"musicbrainz_id": data_dict.get("musicbrainz_album_id"),
}
track_dict = {
"title": data_dict.get("name"),
"musicbrainz_id": data_dict.get("musicbrainz_track_id"),
"run_time_ticks": data_dict.get("run_time_ticks"),
"run_time": data_dict.get("run_time"),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
# Now we run off a scrobble
mopidy_data = {
"user_id": request.user.id,
"timestamp": timezone.now(),
"source": "Mopidy",
"status": data_dict.get("status"),
}
scrobble = None
if track:
scrobble = Scrobble.create_or_update_for_track(
track, request.user.id, mopidy_data
)
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
)

View File

@ -1,7 +1,10 @@
import logging
from typing import Dict, Tuple
from django.db import models
from django_extensions.db.models import TimeStampedModel
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@ -50,3 +53,45 @@ class Video(TimeStampedModel):
if self.video_type == self.VideoType.TV_EPISODE:
return f"{self.tv_series} - Season {self.season_number}, Episode {self.episode_number}"
return self.title
@classmethod
def find_or_create(cls, data_dict: Dict) -> "Video":
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
the video and, if need, TV Series, creating both if they don't yet
exist.
"""
video_dict = {
"title": data_dict.get("Name", ""),
"imdb_id": data_dict.get("Provider_imdb", None),
"video_type": Video.VideoType.MOVIE,
"year": data_dict.get("Year", ""),
"overview": data_dict.get("Overview", None),
"tagline": data_dict.get("Tagline", None),
"run_time_ticks": data_dict.get("RunTimeTicks", None),
"run_time": data_dict.get("RunTime", None),
}
if data_dict.get("ItemType", "") == "Episode":
series_name = data_dict.get("SeriesName", "")
series, series_created = Series.objects.get_or_create(name=series_name)
if series_created:
logger.debug(f"Created new series {series}")
else:
logger.debug(f"Found series {series}")
video_dict['video_type'] = Video.VideoType.TV_EPISODE
video_dict["tv_series_id"] = series.id
video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None)
video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
video_dict["season_number"] = data_dict.get("SeasonNumber", "")
video, created = cls.objects.get_or_create(**video_dict)
if created:
logger.debug(f"Created new video: {video}")
else:
logger.debug(f"Found video {video}")
return video

View File

@ -37,8 +37,22 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
"VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
)
# Should we cull old in-progress scrobbles that are beyond the wait period for resuming?
DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
# Used to dump data coming from srobbling sources, helpful for building new inputs
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
VIDEO_BACKOFF_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 15)
MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 5)
# If you stop waching or listening to a track, how long should we wait before we
# give up on the old scrobble and start a new one? This could also be considered
# a "continue in progress scrobble" time period. So if you pause the media and
# start again, should it be a new scrobble.
VIDEO_WAIT_PERIOD_DAYS = os.getenv("VROBBLER_VIDEO_WAIT_PERIOD_DAYS", 1)
MUSIC_WAIT_PERIOD_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 1)
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@ -70,6 +84,7 @@ INSTALLED_APPS = [
'rest_framework.authtoken',
"scrobbles",
"videos",
"music",
"rest_framework",
"allauth",
"allauth.account",
@ -144,8 +159,8 @@ AUTHENTICATION_BACKENDS = [
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.TokenAuthentication',
#'rest_framework.authentication.BasicAuthentication',
#'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",

View File

@ -16,12 +16,11 @@
dl {
display: flex;
flex-flow: column wrap;
max-height: 6em;
border: 1px solid #777;
}
dt {
padding: 2px 4px;
background: #777;
background: #000;
color: #fff;
}
dd {
@ -30,29 +29,25 @@
min-height: 3em;
border-right: 1px solid #777;
}
#library-update-status { margin-right:10px; }
.card img { width:18em; padding: 1em; }
.card-block { padding: 1em 0 1em 0; }
.system-badge { padding: 1em; font-size: normal; }
.updating { color:#aaa; margin-right: 8px; }
.high { color: green; }
.medium { color: #aaa;}
.low { color: red; }
.card {
height: auto;
display: flex;
.latest-scrobble {
width: 50%;
}
.card-columns{
display: grid;
overflow: hidden;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 1fr;
grid-column-gap: 20px;
grid-row-gap: 10px;
.progress-bar {
width: 100%;
background-color: #e0e0e0;
padding: 3px;
border-radius: 3px;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2);
}
.progress-bar-fill {
display: block;
height: 22px;
background-color: #659cef;
border-radius: 3px;
transition: width 500ms ease-in-out;
}
{% for system in game_systems %}
.{{system.retropie_slug}} { background: #{{system.get_color}}; }
{% endfor %}
</style>
{% block head_extra %}{% endblock %}

View File

@ -5,16 +5,45 @@
{% block content %}
{% if now_playing_list %}
<h2>Now playing</h2>
<ul>
{% for scrobble in now_playing_list %}
<li>{{scrobble.timestamp|date:"D, M j Y"}}: <a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">{{scrobble.video}}</a></li>
{% endfor %}
</ul>
{% for scrobble in now_playing_list %}
{% if scrobble.video %}
<dl class="latest-scrobble">
<dt>{{scrobble.video.title}} - {{scrobble.video}}</dt>
<dd>
{{scrobble.timestamp|date:"D, M j Y"}} |
<a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">IMDB</a>
<div class="progress-bar">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
</dd>
</dl>
{% endif %}
{% if scrobble.track %}
<dl class="latest-scrobble">
<dt>{{scrobble.track.title}} by {{scrobble.track.artist}} from {{scrobble.track.album}}</dt>
<dd>
{{scrobble.timestamp|date:"D, M j Y"}} |
<a href="https://www.imdb.com/title/{{scrobble.track.musicbrainz_id}}">MusicBrainz</a>
<div class="progress-bar">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
</dd>
</dl>
{% endif %}
<br />
{% endfor %}
{% endif %}
<h2>Last scrobbles</h2>
<ul>
{% for scrobble in object_list %}
<li>{{scrobble.timestamp|date:"D, M j Y"}}: <a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">{{scrobble.video}}</a></li>
<li>
{{scrobble.timestamp|date:"D, M j Y"}}:
{% if scrobble.video %}
📼 <a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">{{scrobble.video}}{% if scrobble.video.video_type == 'E' %} - {{scrobble.video.title}}{% endif %}</a></li>
{% endif %}
{% if scrobble.track %}
🎶 <a href="https://musicbrainz.org/recording/{{scrobble.track.album.musicbrainz_id}}">{{scrobble.track}} by {{scrobble.track.artist}}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endblock %}