Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3655cd7934 | |||
| 602d1e0ddb | |||
| 457828e04d | |||
| 49889ae297 | |||
| 4d573bc934 | |||
| bdd0f19161 | |||
| cc0c573c51 | |||
| 28bd9ad504 | |||
| 657b194dc7 | |||
| 27ffd35826 | |||
| da64cb2b6f | |||
| 09e96a55d4 | |||
| e4027402ed | |||
| 4dc1599633 | |||
| 71a8a19491 | |||
| 1ec4333856 | |||
| f98fe4635c |
@ -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>"]
|
||||||
|
|
||||||
|
|||||||
70
vrobbler/apps/music/aggregators.py
Normal file
70
vrobbler/apps/music/aggregators.py
Normal 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()
|
||||||
|
)
|
||||||
8
vrobbler/apps/music/context_processors.py
Normal file
8
vrobbler/apps/music/context_processors.py
Normal 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(),
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
vrobbler/apps/music/migrations/0004_track_thumbs.py
Normal file
25
vrobbler/apps/music/migrations/0004_track_thumbs.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
|
||||||
|
|||||||
@ -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",)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
7
vrobbler/apps/scrobbles/utils.py
Normal file
7
vrobbler/apps/scrobbles/utils.py
Normal 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)
|
||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
8
vrobbler/apps/videos/context_processors.py
Normal file
8
vrobbler/apps/videos/context_processors.py
Normal 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(),
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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}")
|
||||||
|
|||||||
21
vrobbler/apps/videos/urls.py
Normal file
21
vrobbler/apps/videos/urls.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
25
vrobbler/apps/videos/views.py
Normal file
25
vrobbler/apps/videos/views.py
Normal 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'
|
||||||
@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 » 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>
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
12
vrobbler/templates/videos/movie_list.html
Normal file
12
vrobbler/templates/videos/movie_list.html
Normal 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 %}
|
||||||
14
vrobbler/templates/videos/video_detail.html
Normal file
14
vrobbler/templates/videos/video_detail.html
Normal 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 %}
|
||||||
13
vrobbler/templates/videos/video_list.html
Normal file
13
vrobbler/templates/videos/video_list.html
Normal 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 %}
|
||||||
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user