Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3b48099bf | |||
| 1476fe37ca | |||
| 842378e812 | |||
| 07ad6005c8 | |||
| 638be0b56a | |||
| 9c0d85c5d2 | |||
| 1b58ea4538 | |||
| b318bc0da9 | |||
| fe39118b55 |
@ -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
25
templates/base.html
Normal 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>
|
||||
0
vrobbler/apps/music/__init__.py
Normal file
0
vrobbler/apps/music/__init__.py
Normal file
29
vrobbler/apps/music/admin.py
Normal file
29
vrobbler/apps/music/admin.py
Normal 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",)
|
||||
5
vrobbler/apps/music/apps.py
Normal file
5
vrobbler/apps/music/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MusicConfig(AppConfig):
|
||||
name = 'music'
|
||||
16
vrobbler/apps/music/constants.py
Normal file
16
vrobbler/apps/music/constants.py
Normal 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',
|
||||
}
|
||||
156
vrobbler/apps/music/migrations/0001_initial.py
Normal file
156
vrobbler/apps/music/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/music/migrations/0002_alter_album_year.py
Normal file
18
vrobbler/apps/music/migrations/0002_alter_album_year.py
Normal 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),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/music/migrations/__init__.py
Normal file
0
vrobbler/apps/music/migrations/__init__.py
Normal file
79
vrobbler/apps/music/models.py
Normal file
79
vrobbler/apps/music/models.py
Normal 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
|
||||
@ -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",
|
||||
|
||||
3
vrobbler/apps/scrobbles/constants.py
Normal file
3
vrobbler/apps/scrobbles/constants.py
Normal file
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 %}
|
||||
|
||||
|
||||
@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user