Compare commits

...

17 Commits
0.1.7 ... 0.2.0

Author SHA1 Message Date
3655cd7934 Bump version to 0.2.0 2023-01-10 14:41:56 -05:00
602d1e0ddb Add better frontend, the first of many! 2023-01-10 14:40:59 -05:00
457828e04d Fix silly bug in classmethods for Scrobbles 2023-01-10 14:40:09 -05:00
49889ae297 Bump version to 0.1.9 2023-01-09 17:49:46 -05:00
4d573bc934 Fix bug where video scrobbles never start 2023-01-09 17:49:11 -05:00
bdd0f19161 Add start of a rating model for tracks 2023-01-09 11:20:37 -05:00
cc0c573c51 Bump version to 0.1.8 2023-01-09 00:31:46 -05:00
28bd9ad504 Start filling out content urls 2023-01-09 00:29:55 -05:00
657b194dc7 Fix small typo in debug logs 2023-01-08 23:10:24 -05:00
27ffd35826 Fix bug with Jellyfin mbrainz IDs being poor
Turns out Jellyfin uses a really esoteric form of Musicbrainz ID for
tracks. Instead of using the recording ID, it uses the specific hash for
a given version of a recording. A noble effrot to be specific, but we'd
much rather use Mopidy's recording ID when it's available.

Thus, we'll use Jellyfin's ID if that's all we have, but if we scrobble
the same track from Mopidy, overwrite the value.
2023-01-08 23:08:36 -05:00
da64cb2b6f Use logical filters for scrobble admin
No need to filter by video, but in progress and source are pretty handy.
2023-01-08 23:00:34 -05:00
09e96a55d4 First try at fixing bad MD data from Jellyfin 2023-01-08 19:44:41 -05:00
e4027402ed Add scrobble count property to tracks 2023-01-08 19:44:07 -05:00
4dc1599633 Add uuid fields for slugs at some point 2023-01-08 16:51:50 -05:00
71a8a19491 Add context processors for base template 2023-01-08 16:49:13 -05:00
1ec4333856 Add fallback duration tracking for Mopidy
Unlike Jellyfin, Mopidy's webhook only gives us a start and stopped call
to determine when a track should be scrobbled.  This means we don't have
continous updating of playback ticks.

This commit adds a fallback when ticks are not there to use the track
duration and time since the scrobble was created. That said, this is not
perfect. If you pause the track and start again, the progress will get
very out of whack. But thankfully, Mopidy only sends us audio, and it's
rare that audio tracks are paused repeatedly and started again before
finishing a scrobble. So hopefully this shouldn't happen very often.
2023-01-08 14:09:18 -05:00
f98fe4635c Clean up display of last scrobbles 2023-01-08 14:08:57 -05:00
22 changed files with 722 additions and 141 deletions

View File

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

View File

@ -0,0 +1,70 @@
from django.db.models import Q, Count, Sum
from typing import List, Optional
from scrobbles.models import Scrobble
from music.models import Track, Artist
from videos.models import Video
from django.utils import timezone
from datetime import datetime, timedelta
NOW = timezone.now()
START_OF_TODAY = datetime.combine(NOW.date(), datetime.min.time(), NOW.tzinfo)
STARTING_DAY_OF_CURRENT_WEEK = NOW.date() - timedelta(days=NOW.today().isoweekday() % 7)
STARTING_DAY_OF_CURRENT_MONTH = NOW.date().replace(day=1)
STARTING_DAY_OF_CURRENT_YEAR = NOW.date().replace(month=1, day=1)
def scrobble_counts():
finished_scrobbles_qs = Scrobble.objects.filter(in_progress=False)
data = {}
data['today'] = finished_scrobbles_qs.filter(timestamp__gte=START_OF_TODAY).count()
data['week'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK).count()
data['month'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH).count()
data['year'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR).count()
data['alltime'] = finished_scrobbles_qs.count()
return data
def week_of_scrobbles(media: str='tracks') -> dict[str, int]:
scrobble_day_dict= {}
media_filter = Q(track__isnull=True)
for day in range(1,8):
start = START_OF_TODAY - timedelta(days=day)
end = datetime.combine(start, datetime.max.time(), NOW.tzinfo)
day_of_week = start.strftime('%A')
if media == 'movies':
media_filter = Q(video__videotype=Video.VideoType.MOVIE)
if media == 'series':
media_filter = Q(video__videotype=Video.VideoType.MOVIE)
scrobble_day_dict[day_of_week] = Scrobble.objects.filter(media_filter).filter(timestamp__lte=START_OF_TODAY, timestamp__gt=end, in_progress=False).count()
return scrobble_day_dict
def top_tracks(filter: str="today", limit: int=15) -> List["Track"]:
time_filter = Q(scrobble__timestamp__gte=START_OF_TODAY)
if filter == "week":
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK)
if filter == "month":
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH)
if filter == "year":
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
return Track.objects.annotate(num_scrobbles=Count("scrobble", distinct=True)).filter(time_filter).order_by("-num_scrobbles")[:limit]
def top_artists(filter: str="today", limit: int=15) -> List["Artist"]:
time_filter = Q(track__scrobble__timestamp__gte=START_OF_TODAY)
if filter == "week":
time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK)
if filter == "month":
time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH)
if filter == "year":
time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
return Artist.objects.annotate(num_scrobbles=Sum("track__scrobble", distinct=True)).filter(time_filter).order_by("-num_scrobbles")[:limit]
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
return (
Scrobble.objects.filter(track__artist=artist_id)
.count()
)

View File

@ -0,0 +1,8 @@
from music.models import Artist, Album
def music_lists(request):
return {
"artist_list": Artist.objects.all(),
"album_list": Album.objects.all(),
}

View File

@ -0,0 +1,35 @@
# Generated by Django 4.1.5 on 2023-01-08 21:31
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('music', '0002_alter_album_year'),
]
operations = [
migrations.AddField(
model_name='album',
name='uuid',
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
migrations.AddField(
model_name='artist',
name='uuid',
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
migrations.AddField(
model_name='track',
name='uuid',
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-01-09 16:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0003_album_uuid_artist_uuid_track_uuid'),
]
operations = [
migrations.AddField(
model_name='track',
name='thumbs',
field=models.IntegerField(
choices=[
(-1, 'Thumbs down'),
(0, 'No opinion'),
(1, 'Thumbs up'),
],
default=0,
),
),
]

View File

@ -1,15 +1,19 @@
import logging import logging
from typing import Dict, Optional from typing import Dict, Optional
from uuid import uuid4
from django.apps.config import cached_property
from django.db import models from django.db import models
from django_extensions.db.models import TimeStampedModel
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from vrobbler.apps.music.constants import JELLYFIN_POST_KEYS as KEYS from django_extensions.db.models import TimeStampedModel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
class Album(TimeStampedModel): class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
year = models.IntegerField(**BNULL) year = models.IntegerField(**BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL) musicbrainz_id = models.CharField(max_length=255, **BNULL)
@ -19,26 +23,50 @@ class Album(TimeStampedModel):
def __str__(self): def __str__(self):
return self.name return self.name
@property
def mb_link(self):
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
class Artist(TimeStampedModel): class Artist(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
musicbrainz_id = models.CharField(max_length=255, **BNULL) musicbrainz_id = models.CharField(max_length=255, **BNULL)
def __str__(self): def __str__(self):
return self.name return self.name
@property
def mb_link(self):
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
class Track(TimeStampedModel): class Track(TimeStampedModel):
class Opinion(models.IntegerChoices):
DOWN = -1, 'Thumbs down'
NEUTRAL = 0, 'No opinion'
UP = 1, 'Thumbs up'
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL) title = models.CharField(max_length=255, **BNULL)
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING) artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL) album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL) musicbrainz_id = models.CharField(max_length=255, **BNULL)
run_time = models.CharField(max_length=8, **BNULL) run_time = models.CharField(max_length=8, **BNULL)
run_time_ticks = models.PositiveBigIntegerField(**BNULL) run_time_ticks = models.PositiveBigIntegerField(**BNULL)
thumbs = models.IntegerField(default=Opinion.NEUTRAL, choices=Opinion.choices)
def __str__(self): def __str__(self):
return f"{self.title} by {self.artist}" return f"{self.title} by {self.artist}"
@property
def mb_link(self):
return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
@cached_property
def scrobble_count(self):
return self.scrobble_set.filter(in_progress=False).count()
@classmethod @classmethod
def find_or_create( def find_or_create(
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
@ -74,6 +102,6 @@ class Track(TimeStampedModel):
if created: if created:
logger.debug(f"Created new track: {track}") logger.debug(f"Created new track: {track}")
else: else:
logger.debug(f"Found track{track}") logger.debug(f"Found track {track}")
return track return track

View File

@ -13,7 +13,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"playback_position", "playback_position",
"in_progress", "in_progress",
) )
list_filter = ("video",) list_filter = ("in_progress", "source")
ordering = ("-timestamp",) ordering = ("-timestamp",)

View File

@ -42,6 +42,12 @@ class Scrobble(TimeStampedModel):
(self.playback_position_ticks / self.media_run_time_ticks) (self.playback_position_ticks / self.media_run_time_ticks)
* 100 * 100
) )
# If we don't have media_run_time_ticks, let's guess from created time
now = timezone.now()
playback_duration = (now - self.created).seconds
if playback_duration and self.track.run_time:
return int((playback_duration / int(self.track.run_time)) * 100)
return 0 return 0
@property @property
@ -93,7 +99,7 @@ class Scrobble(TimeStampedModel):
f"Creating or updating scrobble for video {video} with data {jellyfin_data}" f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
) )
scrobble = ( scrobble = (
Scrobble.objects.filter(video=video, user_id=user_id) cls.objects.filter(video=video, user_id=user_id)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )
@ -112,7 +118,7 @@ class Scrobble(TimeStampedModel):
) -> "Scrobble": ) -> "Scrobble":
scrobble_data['track_id'] = track.id scrobble_data['track_id'] = track.id
scrobble = ( scrobble = (
Scrobble.objects.filter(track=track, user_id=user_id) cls.objects.filter(track=track, user_id=user_id)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )

View File

@ -0,0 +1,7 @@
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:
run_time_list = run_time.split(":")
run_time = (int(run_time_list[1]) * 60) + int(run_time_list[2])
return int(run_time)

View File

@ -8,6 +8,7 @@ from django.db.models.fields import timezone
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic.list import ListView from django.views.generic.list import ListView
from music.constants import JELLYFIN_POST_KEYS as KEYS
from music.models import Track from music.models import Track
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
@ -18,9 +19,9 @@ from scrobbles.constants import (
) )
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.serializers import ScrobbleSerializer from scrobbles.serializers import ScrobbleSerializer
from scrobbles.utils import convert_to_seconds
from videos.models import Video from videos.models import Video
from vrobbler.apps.music.aggregators import scrobble_counts, top_tracks, week_of_scrobbles
from vrobbler.apps.music.constants import JELLYFIN_POST_KEYS as KEYS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,19 +44,26 @@ class RecentScrobbleList(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
now = timezone.now() now = timezone.now()
last_three_minutes = timezone.now() - timedelta(minutes=3) last_eight_minutes = timezone.now() - timedelta(minutes=8)
# Find scrobbles from the last 10 minutes # Find scrobbles from the last 10 minutes
data['now_playing_list'] = Scrobble.objects.filter( data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True, in_progress=True,
timestamp__gte=last_three_minutes, is_paused=False,
timestamp__gte=last_eight_minutes,
timestamp__lte=now, timestamp__lte=now,
) )
data['video_scrobble_list'] = Scrobble.objects.filter(video__isnull=False, in_progress=False).order_by('-timestamp')[:10]
data['top_daily_tracks'] = top_tracks()
data['top_weekly_tracks'] = top_tracks(filter='week')
data['top_monthly_tracks'] = top_tracks(filter='month')
data["weekly_data"] = week_of_scrobbles()
data['counts'] = scrobble_counts()
return data return data
def get_queryset(self): def get_queryset(self):
return Scrobble.objects.filter(in_progress=False).order_by( return Scrobble.objects.filter(track__isnull=False, in_progress=False).order_by(
'-timestamp' '-timestamp'
) )[:25]
@csrf_exempt @csrf_exempt
@ -103,11 +111,15 @@ def jellyfin_websocket(request):
"musicbrainz_albumartist_id": data_dict.get(KEYS["ARTIST_MB_ID"]), "musicbrainz_albumartist_id": data_dict.get(KEYS["ARTIST_MB_ID"]),
} }
# Convert ticks from Jellyfin from microseconds to nanoseconds
# Ain't nobody got time for nanoseconds
track_dict = { track_dict = {
"title": data_dict.get("Name", ""), "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_ticks": data_dict.get(KEYS["RUN_TIME_TICKS"], None), // 10000,
"run_time": data_dict.get(KEYS["RUN_TIME"], None), "run_time": convert_to_seconds(
data_dict.get(KEYS["RUN_TIME"], None)
),
} }
track = Track.find_or_create(artist_dict, album_dict, track_dict) track = Track.find_or_create(artist_dict, album_dict, track_dict)
@ -123,8 +135,11 @@ def jellyfin_websocket(request):
jellyfin_data = { jellyfin_data = {
"user_id": request.user.id, "user_id": request.user.id,
"timestamp": parse(data_dict.get("UtcTimestamp")), "timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": data_dict.get("PlaybackPositionTicks"), "playback_position_ticks": data_dict.get("PlaybackPositionTicks")
"playback_position": data_dict.get("PlaybackPosition"), // 10000,
"playback_position": convert_to_seconds(
data_dict.get("PlaybackPosition")
),
"source": "Jellyfin", "source": "Jellyfin",
"source_id": data_dict.get('MediaSourceId'), "source_id": data_dict.get('MediaSourceId'),
"is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES, "is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
@ -136,6 +151,10 @@ def jellyfin_websocket(request):
video, request.user.id, jellyfin_data video, request.user.id, jellyfin_data
) )
if track: if track:
# Prefer Mopidy MD IDs to Jellyfin, so skip if we already have one
if not track.musicbrainz_id:
track.musicbrainz_id = data_dict.get(KEYS["TRACK_MB_ID"], None)
track.save()
scrobble = Scrobble.create_or_update_for_track( scrobble = Scrobble.create_or_update_for_track(
track, request.user.id, jellyfin_data track, request.user.id, jellyfin_data
) )
@ -169,7 +188,6 @@ def mopidy_websocket(request):
track_dict = { track_dict = {
"title": data_dict.get("name"), "title": data_dict.get("name"),
"musicbrainz_id": data_dict.get("musicbrainz_track_id"),
"run_time_ticks": data_dict.get("run_time_ticks"), "run_time_ticks": data_dict.get("run_time_ticks"),
"run_time": data_dict.get("run_time"), "run_time": data_dict.get("run_time"),
} }
@ -186,6 +204,9 @@ def mopidy_websocket(request):
scrobble = None scrobble = None
if track: if track:
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
track.save()
scrobble = Scrobble.create_or_update_for_track( scrobble = Scrobble.create_or_update_for_track(
track, request.user.id, mopidy_data track, request.user.id, mopidy_data
) )

View File

@ -0,0 +1,8 @@
from videos.models import Video, Series
def video_lists(request):
return {
"movie_list": Video.objects.filter(video_type=Video.VideoType.MOVIE),
"series_list": Series.objects.all(),
}

View File

@ -0,0 +1,28 @@
# Generated by Django 4.1.5 on 2023-01-08 21:31
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('videos', '0003_alter_video_run_time_ticks'),
]
operations = [
migrations.AddField(
model_name='series',
name='uuid',
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
migrations.AddField(
model_name='video',
name='uuid',
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
]

View File

@ -1,21 +1,31 @@
import logging import logging
from typing import Dict, Tuple from typing import Dict
from uuid import uuid4
from django.db import models from django.db import models
from django_extensions.db.models import TimeStampedModel from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from scrobbles.utils import convert_to_seconds
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
class Series(TimeStampedModel): class Series(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
overview = models.TextField(**BNULL) overview = models.TextField(**BNULL)
tagline = models.TextField(**BNULL) tagline = models.TextField(**BNULL)
# tvdb_id = models.CharField(max_length=20, **BNULL)
# imdb_id = models.CharField(max_length=20, **BNULL)
def __str__(self): def __str__(self):
return self.name return self.name
def imdb_link(self):
return f"https://www.imdb.com/title/{self.imdb_id}"
class Meta: class Meta:
verbose_name_plural = "series" verbose_name_plural = "series"
@ -27,12 +37,13 @@ class Video(TimeStampedModel):
MOVIE = 'M', _('Movie') MOVIE = 'M', _('Movie')
# General fields # General fields
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL)
video_type = models.CharField( video_type = models.CharField(
max_length=1, max_length=1,
choices=VideoType.choices, choices=VideoType.choices,
default=VideoType.UNKNOWN, default=VideoType.UNKNOWN,
) )
title = models.CharField(max_length=255, **BNULL)
overview = models.TextField(**BNULL) overview = models.TextField(**BNULL)
tagline = models.TextField(**BNULL) tagline = models.TextField(**BNULL)
run_time = models.CharField(max_length=8, **BNULL) run_time = models.CharField(max_length=8, **BNULL)
@ -54,6 +65,12 @@ class Video(TimeStampedModel):
return f"{self.tv_series} - Season {self.season_number}, Episode {self.episode_number}" return f"{self.tv_series} - Season {self.season_number}, Episode {self.episode_number}"
return self.title return self.title
def get_absolute_url(self):
return reverse("videos:video_detail", kwargs={'slug': self.uuid})
@property
def imdb_link(self):
return f"https://www.imdb.com/title/{self.imdb_id}"
@classmethod @classmethod
def find_or_create(cls, data_dict: Dict) -> "Video": def find_or_create(cls, data_dict: Dict) -> "Video":
@ -69,13 +86,15 @@ class Video(TimeStampedModel):
"year": data_dict.get("Year", ""), "year": data_dict.get("Year", ""),
"overview": data_dict.get("Overview", None), "overview": data_dict.get("Overview", None),
"tagline": data_dict.get("Tagline", None), "tagline": data_dict.get("Tagline", None),
"run_time_ticks": data_dict.get("RunTimeTicks", None), "run_time_ticks": data_dict.get("RunTimeTicks", 0) // 10000,
"run_time": data_dict.get("RunTime", None), "run_time": convert_to_seconds(data_dict.get("RunTime", "")),
} }
if data_dict.get("ItemType", "") == "Episode": if data_dict.get("ItemType", "") == "Episode":
series_name = data_dict.get("SeriesName", "") series_name = data_dict.get("SeriesName", "")
series, series_created = Series.objects.get_or_create(name=series_name) series, series_created = Series.objects.get_or_create(
name=series_name
)
if series_created: if series_created:
logger.debug(f"Created new series {series}") logger.debug(f"Created new series {series}")
else: else:
@ -87,7 +106,6 @@ class Video(TimeStampedModel):
video_dict["episode_number"] = data_dict.get("EpisodeNumber", "") video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
video_dict["season_number"] = data_dict.get("SeasonNumber", "") video_dict["season_number"] = data_dict.get("SeasonNumber", "")
video, created = cls.objects.get_or_create(**video_dict) video, created = cls.objects.get_or_create(**video_dict)
if created: if created:
logger.debug(f"Created new video: {video}") logger.debug(f"Created new video: {video}")

View File

@ -0,0 +1,21 @@
from django.urls import path
from videos import views
app_name = 'scrobbles'
urlpatterns = [
# path('', views.scrobble_endpoint, name='scrobble-list'),
path("movies/", views.MovieListView.as_view(), name='movie_list'),
path('series/', views.SeriesListView.as_view(), name='series_list'),
path(
'series/<slug:slug>/',
views.SeriesDetailView.as_view(),
name='series_detail',
),
path(
'video/<slug:slug>/',
views.VideoDetailView.as_view(),
name='video_detail',
),
]

View File

@ -0,0 +1,25 @@
from django.views import generic
from videos.models import Series, Video
# class VideoIndexView():
class MovieListView(generic.ListView):
model = Video
def get_queryset(self):
return Video.objects.filter(video_type=Video.VideoType.MOVIE)
class SeriesListView(generic.ListView):
model = Series
class SeriesDetailView(generic.DetailView):
model = Series
slug_field = 'uuid'
class VideoDetailView(generic.DetailView):
model = Video
slug_field = 'uuid'

View File

@ -79,6 +79,7 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sites", "django.contrib.sites",
"django.contrib.humanize",
"django_filters", "django_filters",
"django_extensions", "django_extensions",
'rest_framework.authtoken', 'rest_framework.authtoken',
@ -88,6 +89,7 @@ INSTALLED_APPS = [
"rest_framework", "rest_framework",
"allauth", "allauth",
"allauth.account", "allauth.account",
"allauth.socialaccount",
"django_celery_results", "django_celery_results",
] ]
@ -118,6 +120,8 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"videos.context_processors.video_lists",
"music.context_processors.music_lists",
], ],
}, },
}, },

View File

@ -1,17 +1,17 @@
{% load static %} {% load static %}
{% load humanize %}
<!doctype html> <!doctype html>
<html class="no-js" lang=""> <html class="no-js" lang="">
<head> <head>
<title>{% block page_title %}Welcome{% endblock %} | Vrobbler &raquo; For video scrobbling</title> <title>{% block page_title %}Scrobble all the things{% endblock %} @ Vrobbler</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="description" content=""> <meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
<style type="text/css"> <style type="text/css">
dl { dl {
display: flex; display: flex;
@ -48,76 +48,272 @@
border-radius: 3px; border-radius: 3px;
transition: width 500ms ease-in-out; transition: width 500ms ease-in-out;
} }
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
body {
font-size: .875rem;
}
.feather {
width: 16px;
height: 16px;
vertical-align: text-bottom;
}
/*
* Sidebar
*/
.sidebar {
position: fixed;
top: 0;
/* rtl:raw:
right: 0;
*/
bottom: 0;
/* rtl:remove */
left: 0;
z-index: 100; /* Behind the navbar */
padding: 48px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
@media (max-width: 767.98px) {
.sidebar {
top: 5rem;
}
}
.sidebar-sticky {
position: relative;
top: 0;
height: calc(100vh - 48px);
padding-top: .5rem;
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
}
.sidebar .nav-link {
font-weight: 500;
color: #333;
}
.sidebar .nav-link .feather {
margin-right: 4px;
color: #727272;
}
.sidebar .nav-link.active {
color: #2470dc;
}
.sidebar .nav-link:hover .feather,
.sidebar .nav-link.active .feather {
color: inherit;
}
.sidebar-heading {
font-size: .75rem;
text-transform: uppercase;
}
/*
* Navbar
*/
.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
font-size: 1rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
}
.navbar .navbar-toggler {
top: .25rem;
right: 1rem;
}
.navbar .form-control {
padding: .75rem 1rem;
border-width: 0;
border-radius: 0;
}
.form-control-dark {
color: #fff;
background-color: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .1);
}
.form-control-dark:focus {
border-color: transparent;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
}
</style> </style>
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Place favicon.ico in the root directory --> <!-- Place favicon.ico in the root directory -->
<script>
function checkUpdate(){
$.get('/library/update/status/', function(data) {
$('#library-update-status').html("");
console.log('Checking for task');
setTimeout(checkUpdate,5000);
});
}
</script>
</head> </head>
<body> <body>
<!--[if lt IE 8]> <header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<p class="browserupgrade"> <a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="#">Vrobbler</a>
You are using an <strong>outdated</strong> browser. Please <button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
your experience.
</p>
<![endif]-->
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#">Vrobbler</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<input class="form-control form-control-dark w-100" type="text" placeholder="Search" aria-label="Search">
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="navbar-nav">
<ul class="navbar-nav mr-auto"> <div class="nav-item text-nowrap">
<li class="nav-item"> {% if user.is_authenticated %}
<a class="nav-link" href="{% url 'home' %}">Recent<span class="sr-only"></span></a> <a class="nav-link px-3" href="{% url "account_logout" %}">Sign out</a>
</li> {% else %}
<li class="nav-item dropdown"> <a class="nav-link px-3" href="{% url "account_login" %}">Sign in</a>
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Movies</a> {% endif %}
<div class="dropdown-menu" aria-labelledby="navbarDropdown"> </div>
<a class="dropdown-item" href="{ url "games:gamesystem_list" %}">All</a>
{% for system in game_systems %}
<a class="dropdown-item" href="{{system.get_absolute_url}}">{{system.name}} ({{system.game_set.count}})</a>
{% endfor %}
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Shows</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="{ url "games:gamecollection_list" %}">All</a>
{% for collection in game_collections %}
<a class="dropdown-item" href="{{collection.get_absolute_url}}">{{collection.name}} ({{collection.games.count}})</a>
{% endfor %}
</div>
</li>
</ul>
</div> </div>
{% if request.user.is_authenticated %} </header>
<a class="nav-link" href="{% url 'account_logout' %}">Logout<span class="sr-only"></span></a>
{% else %}
<a class="nav-link" href="{% url 'account_login' %}">Login<span class="sr-only"></span></a>
{% endif %}
</nav>
<h1>{% block title %}{% endblock %}</h1> <div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-3">
{% if now_playing_list %}
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
{% if scrobble.video %}
<div>
{{scrobble.video.title}}<br/>
<small>{{scrobble.created|naturaltime}}<br/>
from {{scrobble.source}}</small>
<div class="progress-bar">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
</div>
{% endif %}
{% if scrobble.track %}
<div>
{{scrobble.track.title}}<br/>
<em>{{scrobble.track.artist}}</em><br/>
<small>{{scrobble.created|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>
</div>
</div>
{% endif %}
<hr/>
{% endfor %}
</ul>
{% endif %}
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">
<span data-feather="music"></span>
Tracks
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<span data-feather="user"></span>
Artists
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<span data-feather="film"></span>
Movies
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<span data-feather="tv"></span>
Series
</a>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/admin/">
<span data-feather="key"></span>
Admin
</a>
</li>
{% endif %}
</ul>
{% block extra_nav %}
{% endblock %}
</div>
</nav>
<div>
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>
</div> </div>
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
<script><!-- comment ------------------------------------------------->
/* globals Chart:false, feather:false */
(function () {
'use strict'
feather.replace({ 'aria-hidden': 'true' })
// Graphs
var ctx = document.getElementById('myChart')
// eslint-disable-next-line no-unused-vars
var myChart = new Chart(ctx, {
type: 'line',
data: {
labels: [
{% for day in weekly_data.keys %}
"{{day}}"{% if not forloop.last %},{% endif %}
{% endfor %}
],
datasets: [{
data: [
{% for count in weekly_data.values %}
{{count}}{% if not forloop.last %},{% endif %}
{% endfor %}
],
lineTension: 0,
backgroundColor: 'transparent',
borderColor: '#007bff',
borderWidth: 4,
pointBackgroundColor: '#007bff'
}]
},
options: {
scales: {
yAxes: [{
ticks: {
beginAtZero: false
}
}]
},
legend: {
display: false
}
}
})
})()
</script>
{% endblock %}
</body> </body>
</html> </html>

View File

@ -1,49 +1,99 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load humanize %}
{% block title %}{% endblock %}
{% block content %} {% block content %}
{% if now_playing_list %} <main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<h2>Now playing</h2> <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
{% for scrobble in now_playing_list %} <h1 class="h2">Dashboard</h1>
{% if scrobble.video %} <div class="btn-toolbar mb-2 mb-md-0">
<dl class="latest-scrobble"> <div class="btn-group me-2">
<dt>{{scrobble.video.title}} - {{scrobble.video}}</dt> <button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
<dd> <button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
{{scrobble.timestamp|date:"D, M j Y"}} | </div>
<a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">IMDB</a> <button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle">
<div class="progress-bar"> <span data-feather="calendar"></span>
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span> This week
</div> </button>
</dd> </div>
</dl> </div>
{% endif %}
{% if scrobble.track %} <canvas class="my-4 w-100" id="myChart" width="900" height="380"></canvas>
<dl class="latest-scrobble">
<dt>{{scrobble.track.title}} by {{scrobble.track.artist}} from {{scrobble.track.album}}</dt>
<dd> <h2>Top this week</h2>
{{scrobble.timestamp|date:"D, M j Y"}} | <div class="table-responsive">
<a href="https://www.imdb.com/title/{{scrobble.track.musicbrainz_id}}">MusicBrainz</a> <table class="table table-striped table-sm">
<div class="progress-bar"> <thead>
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span> <tr>
</div> <th scope="col">#</th>
</dd> <th scope="col">Track</th>
</dl> <th scope="col">Artist</th>
{% endif %} <th scope="col">Album</th>
<br /> </tr>
{% endfor %} </thead>
{% endif %} <tbody>
<h2>Last scrobbles</h2> {% for track in top_weekly_tracks %}
<ul> <tr>
{% for scrobble in object_list %} <td>{{track.num_scrobbles}}</td>
<li> <td>{{track.title}}</td>
{{scrobble.timestamp|date:"D, M j Y"}}: <td>{{track.artist.name}}</td>
{% if scrobble.video %} <td>{{track.album.name}}</td>
📼 <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> </tr>
{% endif %} {% endfor %}
{% if scrobble.track %} </tbody>
🎶 <a href="https://musicbrainz.org/recording/{{scrobble.track.album.musicbrainz_id}}">{{scrobble.track}} by {{scrobble.track.artist}}</a></li> </table>
{% endif %} </div>
{% endfor %}
</ul> <h2>Latest scrobbles</h2>
<p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.artist.name}}</td>
<td>{{scrobble.source}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2>Latest watched</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Title</th>
<th scope="col">Series</th>
<th scope="col">Season & Episode</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{% for scrobble in video_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.season_number}}, {{scrobble.video.episode_number}}{% endif %}</td>
<td>{{scrobble.source}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</main>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h2>Movies</h2>
<ul>
{% for movie in object_list %}
<li>{{movie}}</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Videos{% endblock %}
{% block content %}
{{object}}
{% for scrobble in object.scrobble_set.all %}
<ul>
<li>{{scrobble}}</li>
</ul>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Movies{% endblock %}
{% block content %}
{% for movie in object_list %}
<dl>
<dt>{{movie}}</dt>
{% for scrobble in movie.scrobble_set.all %}
<dd>{{scrobble}}</dd>
{% endfor %}
</dl>
{% endfor %}
{% endblock %}

View File

@ -2,20 +2,11 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from scrobbles.views import RecentScrobbleList
from scrobbles import urls as scrobble_urls
# from scrobbles.api.views import ScrobbleViewSet
# from media_types.api.views import (
# ShowViewSet,
# MovieViewSet,
# )
from rest_framework import routers from rest_framework import routers
from scrobbles.views import RecentScrobbleList
from videos import urls as video_urls
# router = routers.DefaultRouter() from scrobbles import urls as scrobble_urls
# router.register(r"scrobbles", ScrobbleViewSet)
# router.register(r"shows", ShowViewSet)
# router.register(r"movies", MovieViewSet)
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
@ -24,6 +15,7 @@ urlpatterns = [
# path("movies/", include(movies, namespace="movies")), # path("movies/", include(movies, namespace="movies")),
# path("shows/", include(shows, namespace="shows")), # path("shows/", include(shows, namespace="shows")),
path("api/v1/scrobbles/", include(scrobble_urls, namespace="scrobbles")), path("api/v1/scrobbles/", include(scrobble_urls, namespace="scrobbles")),
path("", include(video_urls, namespace="videos")),
path("", RecentScrobbleList.as_view(), name="home"), path("", RecentScrobbleList.as_view(), name="home"),
] ]