Compare commits

..

19 Commits
0.1.9 ... 0.4.0

Author SHA1 Message Date
507b3aaaf2 Bump version to 0.4.0 2023-01-12 16:11:50 -05:00
879357473a Add hack to fix Mopidy progress 2023-01-12 16:07:53 -05:00
cc7d267494 Update repeated attributes for scrobblable models 2023-01-12 16:05:47 -05:00
685c99d023 Fix proper scrobbling of podcasts 2023-01-12 16:05:29 -05:00
69f596039d Add better property for multiple media types
This adds a fun helper method on Scrobble instances to get whatever the
type should be based on media_obj
2023-01-12 15:42:05 -05:00
cf55c9b464 Fix parsing of podcast episode titles 2023-01-12 14:12:10 -05:00
8517212d0e Add podcasts as new media type 2023-01-12 13:56:56 -05:00
f435e60b80 Add very rudimentary fetching of art and metadata from MB 2023-01-12 11:10:25 -05:00
b51b189cd4 Fix sidebar to be accurate 2023-01-11 11:58:56 -05:00
2a20d1212b Bump version to 0.3.0 2023-01-11 11:56:12 -05:00
83b6ba9cc3 Add tabs and clean up main page 2023-01-11 11:55:44 -05:00
4f0d5ad7f4 Bump version to 0.2.2 2023-01-10 23:48:21 -05:00
2b81b28bff Fix bug with duplicate Jellyfin scrobbles 2023-01-10 23:47:26 -05:00
d0c88ce271 Bump version to 0.2.1 2023-01-10 16:14:33 -05:00
f8c9df3b9a Fix artist aggregation so it works 2023-01-10 16:13:52 -05:00
8b61dab1bc Fix aggregator and blacken things up 2023-01-10 15:21:12 -05:00
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
32 changed files with 1428 additions and 236 deletions

21
poetry.lock generated
View File

@ -625,6 +625,14 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
[[package]]
name = "musicbrainzngs"
version = "0.7.1"
description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "0.961" version = "0.961"
@ -1369,7 +1377,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "6105971e3adba942edffa16bd54f5822cdcabcd1e55dfecfc67410cf486a1a71" content-hash = "f8d6b69cfb8ac53b7e03533e8fede68d323363bd73b9c7902d958dc433b1210e"
[metadata.files] [metadata.files]
amqp = [ amqp = [
@ -1719,6 +1727,10 @@ mccabe = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
] ]
musicbrainzngs = [
{file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"},
{file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"},
]
mypy = [ mypy = [
{file = "mypy-0.961-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:697540876638ce349b01b6786bc6094ccdaba88af446a9abb967293ce6eaa2b0"}, {file = "mypy-0.961-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:697540876638ce349b01b6786bc6094ccdaba88af446a9abb967293ce6eaa2b0"},
{file = "mypy-0.961-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b117650592e1782819829605a193360a08aa99f1fc23d1d71e1a75a142dc7e15"}, {file = "mypy-0.961-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b117650592e1782819829605a193360a08aa99f1fc23d1d71e1a75a142dc7e15"},
@ -1769,6 +1781,13 @@ pbr = [
{file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"}, {file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"},
] ]
pillow = [ pillow = [
{file = "Pillow-9.4.0-1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1"},
{file = "Pillow-9.4.0-1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12"},
{file = "Pillow-9.4.0-1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd"},
{file = "Pillow-9.4.0-1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9"},
{file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
{file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
{file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"}, {file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"}, {file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"}, {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "0.1.9" version = "0.4.0"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]
@ -27,6 +27,7 @@ django-markdownify = "^0.9.1"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
django-simple-history = "^3.1.1" django-simple-history = "^3.1.1"
whitenoise = "^6.3.0" whitenoise = "^6.3.0"
musicbrainzngs = "^0.7.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
Werkzeug = "2.0.3" Werkzeug = "2.0.3"

View File

@ -2,12 +2,17 @@ from django.contrib import admin
from music.models import Artist, Album, Track from music.models import Artist, Album, Track
@admin.register(Album) @admin.register(Album)
class AlbumAdmin(admin.ModelAdmin): class AlbumAdmin(admin.ModelAdmin):
date_hierarchy = "created" date_hierarchy = "created"
list_display = ("name", "year", "musicbrainz_id") list_display = ("name", "year", "musicbrainz_id")
list_filter = ("year",) list_filter = ("year",)
ordering = ("name",) ordering = ("name",)
filter_horizontal = [
'artists',
]
@admin.register(Artist) @admin.register(Artist)
class ArtistAdmin(admin.ModelAdmin): class ArtistAdmin(admin.ModelAdmin):
@ -15,6 +20,7 @@ class ArtistAdmin(admin.ModelAdmin):
list_display = ("name", "musicbrainz_id") list_display = ("name", "musicbrainz_id")
ordering = ("name",) ordering = ("name",)
@admin.register(Track) @admin.register(Track)
class TrackAdmin(admin.ModelAdmin): class TrackAdmin(admin.ModelAdmin):
date_hierarchy = "created" date_hierarchy = "created"

View File

@ -0,0 +1,105 @@
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=False)
for day in range(6, -1, -1):
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.TV_EPISODE)
scrobble_day_dict[day_of_week] = (
Scrobble.objects.filter(media_filter)
.filter(
timestamp__gte=start,
timestamp__lte=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=Count("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,35 @@
# Generated by Django 4.1.5 on 2023-01-11 03:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0003_album_uuid_artist_uuid_track_uuid'),
]
operations = [
migrations.AlterModelOptions(
name='artist',
options={},
),
migrations.AlterField(
model_name='album',
name='musicbrainz_id',
field=models.CharField(
blank=True, max_length=255, null=True, unique=True
),
),
migrations.AlterField(
model_name='track',
name='musicbrainz_id',
field=models.CharField(
blank=True, max_length=255, null=True, unique=True
),
),
migrations.AlterUniqueTogether(
name='artist',
unique_together={('name', 'musicbrainz_id')},
),
]

View File

@ -1,25 +0,0 @@
# 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

@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-01-12 04:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
'music',
'0004_alter_artist_options_alter_album_musicbrainz_id_and_more',
),
]
operations = [
migrations.AddField(
model_name='album',
name='cover_image',
field=models.ImageField(
blank=True, null=True, upload_to='albums/'
),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.1.5 on 2023-01-12 05:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0005_album_cover_image'),
]
operations = [
migrations.AddField(
model_name='album',
name='artists',
field=models.ManyToManyField(
blank=True, null=True, to='music.artist'
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-12 17:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0006_album_artists'),
]
operations = [
migrations.AlterField(
model_name='album',
name='artists',
field=models.ManyToManyField(to='music.artist'),
),
]

View File

@ -2,37 +2,26 @@ import logging
from typing import Dict, Optional from typing import Dict, Optional
from uuid import uuid4 from uuid import uuid4
import musicbrainzngs
from django.apps.config import cached_property from django.apps.config import cached_property
from django.core.files.base import ContentFile
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
year = models.IntegerField(**BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.name
@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) 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)
class Meta:
unique_together = [['name', 'musicbrainz_id']]
def __str__(self): def __str__(self):
return self.name return self.name
@ -41,20 +30,68 @@ class Artist(TimeStampedModel):
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}" return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
class Track(TimeStampedModel): class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
artists = models.ManyToManyField(Artist)
year = models.IntegerField(**BNULL)
musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
cover_image = models.ImageField(upload_to="albums/", **BNULL)
def __str__(self):
return self.name
@property
def primary_artist(self):
return self.artists.first()
def fix_metadata(self):
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
mb_data = musicbrainzngs.get_release_by_id(
self.musicbrainz_id, includes=['artists']
)
if not self.musicbrainz_albumartist_id:
self.musicbrainz_albumartist_id = mb_data['release'][
'artist-credit'
][0]['artist']['id']
if not self.year:
self.year = mb_data['release']['date'][0:4]
self.save(update_fields=['musicbrainz_albumartist_id', 'year'])
new_artist = Artist.objects.filter(
musicbrainz_id=self.musicbrainz_albumartist_id
).first()
if self.musicbrainz_albumartist_id and new_artist:
self.artists.add(new_artist)
if not new_artist:
for t in self.track_set.all():
self.artists.add(t.artist)
def fetch_artwork(self):
try:
img_data = musicbrainzngs.get_image_front(self.musicbrainz_id)
name = f"{self.name}_{self.uuid}.jpg"
self.cover_image = ContentFile(img_data, name=name)
self.save()
except musicbrainzngs.ResponseError:
logger.warning(f'No cover art found for {self.name}')
@property
def mb_link(self):
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
class Track(ScrobblableMixin):
class Opinion(models.IntegerChoices): class Opinion(models.IntegerChoices):
DOWN = -1, 'Thumbs down' DOWN = -1, 'Thumbs down'
NEUTRAL = 0, 'No opinion' NEUTRAL = 0, 'No opinion'
UP = 1, 'Thumbs up' UP = 1, 'Thumbs up'
uuid = models.UUIDField(default=uuid4, editable=False, **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, unique=True, **BNULL)
run_time = models.CharField(max_length=8, **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}"
@ -80,9 +117,10 @@ class Track(TimeStampedModel):
'musicbrainz_id' 'musicbrainz_id'
): ):
logger.warning( logger.warning(
f"No artist or artist musicbrainz ID found in message from Jellyfin, not scrobbling" f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
) )
return return
artist, artist_created = Artist.objects.get_or_create(**artist_dict) artist, artist_created = Artist.objects.get_or_create(**artist_dict)
if artist_created: if artist_created:
logger.debug(f"Created new album {artist}") logger.debug(f"Created new album {artist}")
@ -94,6 +132,8 @@ class Track(TimeStampedModel):
logger.debug(f"Created new album {album}") logger.debug(f"Created new album {album}")
else: else:
logger.debug(f"Found album {album}") logger.debug(f"Found album {album}")
album.fix_metadata()
album.fetch_artwork()
track_dict['album_id'] = getattr(album, "id", None) track_dict['album_id'] = getattr(album, "id", None)
track_dict['artist_id'] = artist.id track_dict['artist_id'] = artist.id

View File

View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
from django.contrib import admin
from podcasts.models import Episode, Podcast, Producer
@admin.register(Producer)
class ProducerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name",)
ordering = ("name",)
@admin.register(Podcast)
class PodcastAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"producer",
"active",
)
ordering = ("name",)
@admin.register(Episode)
class EpisodeAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"podcast",
"run_time",
)
list_filter = ("podcast",)
ordering = ("-created",)

View File

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

View File

@ -0,0 +1,158 @@
# Generated by Django 4.1.5 on 2023-01-12 17:18
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='Producer',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
('name', models.CharField(max_length=255)),
(
'uuid',
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.CreateModel(
name='Podcast',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
('name', models.CharField(max_length=255)),
(
'uuid',
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
('active', models.BooleanField(default=True)),
('url', models.URLField(blank=True, null=True)),
(
'producer',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='podcasts.producer',
),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.CreateModel(
name='Episode',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
('title', models.CharField(max_length=255)),
(
'uuid',
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
'mopidy_uri',
models.CharField(blank=True, max_length=255, null=True),
),
(
'podcast',
models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
to='podcasts.producer',
),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 4.1.5 on 2023-01-12 17:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='episode',
name='run_time',
field=models.CharField(blank=True, max_length=8, null=True),
),
migrations.AddField(
model_name='episode',
name='run_time_ticks',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='episode',
name='podcast',
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
to='podcasts.podcast',
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-12 18:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0002_episode_run_time_episode_run_time_ticks_and_more'),
]
operations = [
migrations.AddField(
model_name='episode',
name='pub_date',
field=models.DateField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-12 18:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0003_episode_pub_date'),
]
operations = [
migrations.AddField(
model_name='episode',
name='number',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,85 @@
import logging
from typing import Dict, Optional
from uuid import uuid4
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from vrobbler.apps.scrobbles.mixins import ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
class Producer(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
def __str__(self):
return f"{self.name}"
class Podcast(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
producer = models.ForeignKey(
Producer, on_delete=models.DO_NOTHING, **BNULL
)
active = models.BooleanField(default=True)
url = models.URLField(**BNULL)
def __str__(self):
return f"{self.name}"
class Episode(ScrobblableMixin):
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
number = models.IntegerField(**BNULL)
pub_date = models.DateField(**BNULL)
mopidy_uri = models.CharField(max_length=255, **BNULL)
def __str__(self):
return f"{self.title}"
@classmethod
def find_or_create(
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
) -> Optional["Episode"]:
"""Given a data dict from Mopidy, finds or creates a podcast and
producer before saving the epsiode so it can be scrobbled.
"""
if not podcast_dict.get('name'):
logger.warning(f"No name from source for podcast, not scrobbling")
return
producer = None
if producer_dict.get('name'):
producer, producer_created = Producer.objects.get_or_create(
**producer_dict
)
if producer_created:
logger.debug(f"Created new producer {producer}")
else:
logger.debug(f"Found producer {producer}")
if producer:
podcast_dict["producer_id"] = producer.id
podcast, podcast_created = Podcast.objects.get_or_create(
**podcast_dict
)
if podcast_created:
logger.debug(f"Created new podcast {podcast}")
else:
logger.debug(f"Found podcast {podcast}")
episode_dict['podcast_id'] = podcast.id
episode, created = cls.objects.get_or_create(**episode_dict)
if created:
logger.debug(f"Created new episode: {episode}")
else:
logger.debug(f"Found episode {episode}")
return episode

View File

@ -3,18 +3,33 @@ from django.contrib import admin
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
@admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin): class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp" date_hierarchy = "timestamp"
list_display = ( list_display = (
"timestamp", "timestamp",
"video", "media_name",
"track", "media_type",
"source", "source",
"playback_position", "playback_position",
"in_progress", "in_progress",
"is_paused",
) )
list_filter = ("in_progress", "source") list_filter = ("is_paused", "in_progress", "source", "track__artist")
ordering = ("-timestamp",) ordering = ("-timestamp",)
def media_name(self, obj):
if obj.video:
return obj.video
if obj.track:
return obj.track
if obj.podcast_episode:
return obj.podcast_episode
admin.site.register(Scrobble, ScrobbleAdmin) def media_type(self, obj):
if obj.video:
return "Video"
if obj.track:
return "Track"
if obj.podcast_episode:
return "Podcast"

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-01-12 17:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0002_episode_run_time_episode_run_time_ticks_and_more'),
('scrobbles', '0006_scrobble_track_alter_scrobble_video'),
]
operations = [
migrations.AddField(
model_name='scrobble',
name='podcast_episode',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='podcasts.episode',
),
),
]

View File

@ -0,0 +1,17 @@
from uuid import uuid4
from django.db import models
from django_extensions.db.models import TimeStampedModel
BNULL = {"blank": True, "null": True}
class ScrobblableMixin(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL)
run_time = models.CharField(max_length=8, **BNULL)
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
# thumbs = models.IntegerField(default=Opinion.NEUTRAL, choices=Opinion.choices)
class Meta:
abstract = True

View File

@ -8,6 +8,8 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from music.models import Track from music.models import Track
from podcasts.models import Episode
from scrobbles.utils import check_scrobble_for_finish
from videos.models import Video from videos.models import Video
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,6 +24,9 @@ TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
class Scrobble(TimeStampedModel): class Scrobble(TimeStampedModel):
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL) video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL) track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
podcast_episode = models.ForeignKey(
Episode, on_delete=models.DO_NOTHING, **BNULL
)
user = models.ForeignKey( user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING User, blank=True, null=True, on_delete=models.DO_NOTHING
) )
@ -37,28 +42,25 @@ class Scrobble(TimeStampedModel):
@property @property
def percent_played(self) -> int: def percent_played(self) -> int:
if self.playback_position_ticks and self.media_run_time_ticks: if (
self.playback_position_ticks
and self.media_obj.run_time_ticks
and source != 'Mopidy'
):
return int( return int(
(self.playback_position_ticks / self.media_run_time_ticks) (self.playback_position_ticks / self.media_obj.run_time_ticks)
* 100 * 100
) )
# If we don't have media_run_time_ticks, let's guess from created time # If we don't have media_obj.run_time_ticks, let's guess from created time
now = timezone.now() now = timezone.now()
playback_duration = (now - self.created).seconds playback_duration = (now - self.created).seconds
if playback_duration and self.track.run_time: if playback_duration and self.media_obj.run_time:
return int((playback_duration / int(self.track.run_time)) * 100) return int(
(playback_duration / int(self.media_obj.run_time)) * 100
)
return 0 return 0
@property
def media_run_time_ticks(self) -> int:
if self.video:
return self.video.run_time_ticks
if self.track:
return self.track.run_time_ticks
# this is hacky, but want to avoid divide by zero
return 1
def is_stale(self, backoff, wait_period) -> bool: def is_stale(self, backoff, wait_period) -> bool:
scrobble_is_stale = self.in_progress and self.modified > wait_period scrobble_is_stale = self.in_progress and self.modified > wait_period
@ -79,16 +81,19 @@ class Scrobble(TimeStampedModel):
return scrobble_is_stale return scrobble_is_stale
def __str__(self): @property
media = None def media_obj(self):
media_obj = None
if self.video: if self.video:
media = self.video media_obj = self.video
if self.track: if self.track:
media = self.track media_obj = self.track
if self.podcast_episode:
media_obj = self.podcast_episode
return media_obj
return ( def __str__(self):
f"Scrobble of {media} {self.timestamp.year}-{self.timestamp.month}" return f"Scrobble of {self.media_obj} {self.timestamp.year}-{self.timestamp.month}"
)
@classmethod @classmethod
def create_or_update_for_video( def create_or_update_for_video(
@ -99,7 +104,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()
) )
@ -118,7 +123,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()
) )
@ -134,6 +139,28 @@ class Scrobble(TimeStampedModel):
scrobble, backoff, wait_period, scrobble_data scrobble, backoff, wait_period, scrobble_data
) )
@classmethod
def create_or_update_for_podcast_episode(
cls, episode: "Episode", user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['podcast_episode_id'] = episode.id
scrobble = (
cls.objects.filter(podcast_episode=episode, user_id=user_id)
.order_by('-modified')
.first()
)
logger.debug(
f"Found existing scrobble for podcast {episode}, updating",
{"scrobble_data": scrobble_data},
)
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
return cls.update_or_create(
scrobble, backoff, wait_period, scrobble_data
)
@classmethod @classmethod
def update_or_create( def update_or_create(
cls, cls,
@ -147,6 +174,13 @@ class Scrobble(TimeStampedModel):
mopidy_status = scrobble_data.pop('status', None) mopidy_status = scrobble_data.pop('status', None)
scrobble_is_stale = False scrobble_is_stale = False
if scrobble:
logger.debug(f"Updating scrobble ticks")
scrobble.playback_position_ticks = scrobble_data.get(
"playback_position_ticks"
)
scrobble.save(update_fields=['playback_position_ticks'])
if mopidy_status == "stopped": if mopidy_status == "stopped":
logger.info(f"Mopidy sent a message to stop {scrobble}") logger.info(f"Mopidy sent a message to stop {scrobble}")
if not scrobble: if not scrobble:
@ -157,7 +191,37 @@ class Scrobble(TimeStampedModel):
# Mopidy finished a play, scrobble away # Mopidy finished a play, scrobble away
scrobble.in_progress = False scrobble.in_progress = False
scrobble.save(update_fields=['in_progress']) scrobble.played_to_completion = True
scrobble.save(
update_fields=['in_progress', 'played_to_completion']
)
return scrobble
if mopidy_status == "paused":
logger.info(f"Mopidy sent a message to pause {scrobble}")
if not scrobble:
logger.info("Message to pause while not started, ignoring")
return
if scrobble.is_paused:
logger.info("Message to pause while paused, ignoring")
return
# Mopidy finished a play, scrobble away
scrobble.is_paused = True
scrobble.save(update_fields=["is_paused"])
scrobble = check_scrobble_for_finish(scrobble)
return scrobble
if mopidy_status == "resumed":
logger.info(f"Mopidy sent a message to resume {scrobble}")
if not scrobble:
logger.info("Message to resume while not started, ignoring")
return
if not scrobble.is_paused:
logger.info("Message to resume while not paused, resuming")
# Mopidy finished a play, scrobble away
scrobble.is_paused = False
scrobble.save(update_fields=["is_paused"])
return scrobble return scrobble
if scrobble and not mopidy_status: if scrobble and not mopidy_status:
@ -185,16 +249,6 @@ class Scrobble(TimeStampedModel):
# If we hit our completion threshold, save it and get ready # If we hit our completion threshold, save it and get ready
# to scrobble again if we re-watch this. # to scrobble again if we re-watch this.
if scrobble.percent_played >= getattr( scrobble = check_scrobble_for_finish(scrobble)
settings, "PERCENT_FOR_COMPLETION", 95
):
scrobble.in_progress = False
scrobble.playback_position_ticks = scrobble.media_run_time_ticks
scrobble.save()
if scrobble.percent_played % 5 == 0:
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
scrobble.save(update_fields=['scrobble_log'])
return scrobble return scrobble

View File

@ -0,0 +1,93 @@
import logging
from typing import Optional
from django.utils import timezone
from music.models import Track
from podcasts.models import Episode
from scrobbles.models import Scrobble
from scrobbles.utils import parse_mopidy_uri
logger = logging.getLogger(__name__)
def mopidy_scrobble_podcast(
data_dict: dict, user_id: Optional[int]
) -> Scrobble:
mopidy_uri = data_dict.get("mopidy_uri", "")
parsed_data = parse_mopidy_uri(mopidy_uri)
producer_dict = {"name": data_dict.get("artist")}
podcast_name = data_dict.get("album")
if not podcast_name:
podcast_name = parsed_data.get("podcast_name")
podcast_dict = {"name": podcast_name}
episode_name = parsed_data.get("episode_filename")
episode_dict = {
"title": episode_name,
"run_time_ticks": data_dict.get("run_time_ticks"),
"run_time": data_dict.get("run_time"),
"number": parsed_data.get("episode_num"),
"pub_date": parsed_data.get("pub_date"),
"mopidy_uri": mopidy_uri,
}
episode = Episode.find_or_create(podcast_dict, producer_dict, episode_dict)
# Now we run off a scrobble
mopidy_data = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_ticks": data_dict.get("playback_time_ticks"),
"source": "Mopidy",
"status": data_dict.get("status"),
}
scrobble = None
if episode:
scrobble = Scrobble.create_or_update_for_podcast_episode(
episode, user_id, mopidy_data
)
return scrobble
def mopidy_scrobble_track(
data_dict: dict, user_id: Optional[int]
) -> Optional[Scrobble]:
artist_dict = {
"name": data_dict.get("artist", None),
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
}
album_dict = {
"name": data_dict.get("album"),
"musicbrainz_id": data_dict.get("musicbrainz_album_id"),
}
track_dict = {
"title": data_dict.get("name"),
"run_time_ticks": data_dict.get("run_time_ticks"),
"run_time": data_dict.get("run_time"),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
# Now we run off a scrobble
mopidy_data = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_ticks": data_dict.get("playback_time_ticks"),
"source": "Mopidy",
"status": data_dict.get("status"),
}
scrobble = None
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(
track, user_id, mopidy_data
)
return scrobble

View File

@ -1,3 +1,13 @@
import logging
from typing import Any
from urllib.parse import unquote
from dateutil.parser import ParserError, parse
from django.conf import settings
logger = logging.getLogger(__name__)
def convert_to_seconds(run_time: str) -> int: def convert_to_seconds(run_time: str) -> int:
"""Jellyfin sends run time as 00:00:00 string. We want the run time to """Jellyfin sends run time as 00:00:00 string. We want the run time to
actually be in seconds so we'll convert it""" actually be in seconds so we'll convert it"""
@ -5,3 +15,77 @@ def convert_to_seconds(run_time: str) -> int:
run_time_list = run_time.split(":") run_time_list = run_time.split(":")
run_time = (int(run_time_list[1]) * 60) + int(run_time_list[2]) run_time = (int(run_time_list[1]) * 60) + int(run_time_list[2])
return int(run_time) return int(run_time)
def parse_mopidy_uri(uri: str) -> dict:
logger.debug(f"Parsing URI: {uri}")
parsed_uri = uri.split('/')
episode_str = unquote(parsed_uri.pop(-1).strip(".mp3"))
podcast_str = unquote(parsed_uri.pop(-1))
possible_date_str = episode_str[0:10]
try:
pub_date = parse(possible_date_str)
except ParserError:
pub_date = ""
logger.debug(f"Found pub date {pub_date} from Mopidy URI")
try:
if pub_date:
episode_num = int(episode_str.split('-')[3])
else:
episode_num = int(episode_str.split('-')[0])
except IndexError:
episode_num = None
except ValueError:
episode_num = None
logger.debug(f"Found episode num {episode_num} from Mopidy URI")
if pub_date:
episode_str = episode_str.strip(episode_str[:11])
if type(episode_num) is int:
episode_num_gap = len(str(episode_num)) + 1
episode_str = episode_str.strip(episode_str[:episode_num_gap])
episode_str = episode_str.replace('-', ' ')
logger.debug(f"Found episode name {episode_str} from Mopidy URI")
return {
'episode_filename': episode_str,
'episode_num': episode_num,
'podcast_name': podcast_str,
'pub_date': pub_date,
}
def check_scrobble_for_finish(scrobble: "Scrobble") -> None:
completion_percent = getattr(settings, "MUSIC_COMPLETION_PERCENT", 90)
if scrobble.podcast_episode:
completion_percent = getattr(
settings, "PODCAST_COMPLETION_PERCENT", 25
)
logger.debug(f"Completion set to {completion_percent}")
if scrobble.percent_played >= completion_percent:
logger.debug(
f"{scrobble} meets completion goal of {completion_percent}, finishing"
)
scrobble.in_progress = False
scrobble.is_paused = False
scrobble.playback_position_ticks = scrobble.media_obj.run_time_ticks
scrobble.save(
update_fields=[
"in_progress",
"is_paused",
"playback_position_ticks",
]
)
if scrobble.percent_played % 5 == 0:
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
scrobble.save(update_fields=['scrobble_log'])
return scrobble

View File

@ -21,6 +21,13 @@ from scrobbles.models import Scrobble
from scrobbles.serializers import ScrobbleSerializer from scrobbles.serializers import ScrobbleSerializer
from scrobbles.utils import convert_to_seconds 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_artists,
top_tracks,
week_of_scrobbles,
)
from scrobbles.scrobblers import mopidy_scrobble_podcast, mopidy_scrobble_track
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,19 +50,33 @@ 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['top_daily_artists'] = top_artists()
data['top_weekly_artists'] = top_artists(filter='week')
data['top_monthly_artists'] = top_artists(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(
'-timestamp' track__isnull=False, in_progress=False
) ).order_by('-timestamp')[:15]
@csrf_exempt @csrf_exempt
@ -167,41 +188,12 @@ def mopidy_websocket(request):
# For making things easier to build new input processors # For making things easier to build new input processors
if getattr(settings, "DUMP_REQUEST_DATA", False): if getattr(settings, "DUMP_REQUEST_DATA", False):
json_data = json.dumps(data_dict, indent=4) json_data = json.dumps(data_dict, indent=4)
logger.debug(f"{json_data}")
artist_dict = { if 'podcast' in data_dict.get('mopidy_uri'):
"name": data_dict.get("artist", None), scrobble = mopidy_scrobble_podcast(data_dict, request.user.id)
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None), else:
} scrobble = mopidy_scrobble_track(data_dict, request.user.id)
album_dict = {
"name": data_dict.get("album"),
"musicbrainz_id": data_dict.get("musicbrainz_album_id"),
}
track_dict = {
"title": data_dict.get("name"),
"run_time_ticks": data_dict.get("run_time_ticks"),
"run_time": data_dict.get("run_time"),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
# Now we run off a scrobble
mopidy_data = {
"user_id": request.user.id,
"timestamp": timezone.now(),
"source": "Mopidy",
"status": data_dict.get("status"),
}
scrobble = None
if track:
# 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(
track, request.user.id, mopidy_data
)
if not scrobble: if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({}, status=status.HTTP_400_BAD_REQUEST)

View File

@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-01-11 03:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('videos', '0004_series_uuid_video_uuid'),
]
operations = [
migrations.AlterModelOptions(
name='video',
options={},
),
migrations.AlterUniqueTogether(
name='video',
unique_together={('title', 'imdb_id')},
),
]

View File

@ -7,6 +7,7 @@ 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 django_extensions.db.models import TimeStampedModel
from scrobbles.utils import convert_to_seconds from scrobbles.utils import convert_to_seconds
from scrobbles.mixins import ScrobblableMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -30,15 +31,12 @@ class Series(TimeStampedModel):
verbose_name_plural = "series" verbose_name_plural = "series"
class Video(TimeStampedModel): class Video(ScrobblableMixin):
class VideoType(models.TextChoices): class VideoType(models.TextChoices):
UNKNOWN = 'U', _('Unknown') UNKNOWN = 'U', _('Unknown')
TV_EPISODE = 'E', _('TV Episode') TV_EPISODE = 'E', _('TV Episode')
MOVIE = 'M', _('Movie') MOVIE = 'M', _('Movie')
# 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,
@ -46,8 +44,6 @@ class Video(TimeStampedModel):
) )
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_ticks = models.PositiveBigIntegerField(**BNULL)
year = models.IntegerField() year = models.IntegerField()
# TV show specific fields # TV show specific fields
@ -58,7 +54,8 @@ class Video(TimeStampedModel):
imdb_id = models.CharField(max_length=20, **BNULL) imdb_id = models.CharField(max_length=20, **BNULL)
tvrage_id = models.CharField(max_length=20, **BNULL) tvrage_id = models.CharField(max_length=20, **BNULL)
# Metadata fields from TMDB class Meta:
unique_together = [['title', 'imdb_id']]
def __str__(self): def __str__(self):
if self.video_type == self.VideoType.TV_EPISODE: if self.video_type == self.VideoType.TV_EPISODE:
@ -107,6 +104,7 @@ class Video(TimeStampedModel):
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}")
else: else:

View File

@ -6,15 +6,18 @@ from os import environ as env
if not 'DJANGO_SETTINGS_MODULE' in env: if not 'DJANGO_SETTINGS_MODULE' in env:
from vrobbler import settings from vrobbler import settings
env.setdefault('DJANGO_SETTINGS_MODULE', settings.__name__) env.setdefault('DJANGO_SETTINGS_MODULE', settings.__name__)
import django import django
django.setup() django.setup()
# this line must be after django.setup() for logging configure # this line must be after django.setup() for logging configure
logger = logging.getLogger('vrobbler') logger = logging.getLogger('vrobbler')
def main(): def main():
# to get configured settings # to get configured settings
from django.conf import settings from django.conf import settings

View File

@ -37,6 +37,11 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
"VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False "VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
) )
PODCAST_COMPLETION_PERCENT = os.getenv(
"VROBBLER_PODCAST_COMPLETION_PERCENT", 25
)
MUSIC_COMPLETION_PERCENT = os.getenv("VROBBLER_MUSIC_COMPLETION_PERCENT", 90)
# Should we cull old in-progress scrobbles that are beyond the wait period for resuming? # Should we cull old in-progress scrobbles that are beyond the wait period for resuming?
DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True) DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
@ -86,9 +91,11 @@ INSTALLED_APPS = [
"scrobbles", "scrobbles",
"videos", "videos",
"music", "music",
"podcasts",
"rest_framework", "rest_framework",
"allauth", "allauth",
"allauth.account", "allauth.account",
"allauth.socialaccount",
"django_celery_results", "django_celery_results",
] ]

View File

@ -1,4 +1,5 @@
{% load static %} {% load static %}
{% load humanize %}
<!doctype html> <!doctype html>
<html class="no-js" lang=""> <html class="no-js" lang="">
<head> <head>
@ -7,11 +8,10 @@
<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,267 @@
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 "videos:movie_list" %}">All</a>
{% for movie in movie_list %}
<a class="dropdown-item" href="{{movie.get_absolute_url}}">{{movie.title}}</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 series in series_list %}
<a class="dropdown-item" href="{{series.get_absolute_url}}">{{series.name}}</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 %}
<div>
{{scrobble.media_obj.title}}<br/>
{% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %}
{% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %}
<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>
<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>
Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="/tracks/">
<span data-feather="music"></span>
Tracks
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/artists/">
<span data-feather="user"></span>
Artists
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/movies/">
<span data-feather="film"></span>
Movies
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/series/">
<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: '#007bf0',
borderWidth: 4,
pointBackgroundColor: '#007bff'
}]
},
options: {
scales: {
yAxes: [{
ticks: {
beginAtZero: false
}
}]
},
legend: {
display: false
}
}
})
})()
</script>
{% endblock %}
</body> </body>
</html> </html>

View File

@ -1,50 +1,151 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load humanize %} {% load humanize %}
{% block title %}{% endblock %}
{% block content %} {% block content %}
{% if now_playing_list %} <main class="col-md-4 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>
Started {{scrobble.created|naturaltime}} from {{scrobble.source}} </div>
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle">
<span data-feather="calendar"></span>
This week
</button>
</div>
</div>
<div class="progress-bar"> <canvas class="my-4 w-100" id="myChart" width="900" height="220"></canvas>
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
<div class="container">
<div class="row">
<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>
<div class="row">
<div class="col-md">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#artists-week" type="button" role="tab" aria-controls="home" aria-selected="true">Weekly Artists</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-week" type="button" role="tab" aria-controls="profile" aria-selected="false">Weekly Tracks</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show" id="artists-week" role="tabpanel" aria-labelledby="artists-week-tab">
<h2>Top artists this week</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for artist in top_weekly_artists %}
<tr>
<td>{{artist.num_scrobbles}}</td>
<td>{{artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</dd>
</dl> <div class="tab-pane fade show active" id="tracks-week" role="tabpanel" aria-labelledby="tracks-week-tab">
{% endif %} <h2>Top tracks this week</h2>
{% if scrobble.track %} <div class="table-responsive">
<dl class="latest-scrobble"> <table class="table table-striped table-sm">
<dt>{{scrobble.track.title}} by {{scrobble.track.artist}} from {{scrobble.track.album}}</dt> <thead>
<dd> <tr>
Started {{scrobble.created|naturaltime}} from {{scrobble.source}} <th scope="col">#</th>
<div class="progress-bar"> <th scope="col">Track</th>
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span> <th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for track in top_weekly_tracks %}
<tr>
<td>{{track.num_scrobbles}}</td>
<td>{{track.title}}</td>
<td>{{track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</dd> </div>
</dl> </div>
{% endif %} <div class="col-md">
<br /> <ul class="nav nav-tabs" id="myTab" role="tablist">
{% endfor %} <li class="nav-item" role="presentation">
{% endif %} <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened" type="button" role="tab" aria-controls="home" aria-selected="true">Latest Listened</button>
<h2>Last scrobbles</h2> </li>
<ul> <li class="nav-item" role="presentation">
{% for scrobble in object_list %} <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched" type="button" role="tab" aria-controls="profile" aria-selected="false">Latest Watched</button>
<li> </li>
{{scrobble.timestamp|naturaltime}}: </ul>
{% if scrobble.video %}
🎥 watched <a href="{{scrobble.video.imdb_link}}">{{scrobble.video}}{% if scrobble.video.video_type == 'E' %} - {{scrobble.video.title}}{% endif %}</a></li> <div class="tab-content" id="myTabContent2">
{% endif %} <div class="tab-pane fade show active" id="latest-listened" role="tabpanel" aria-labelledby="latest-listened-tab">
{% if scrobble.track %} <h2>Latest listened</h2>
🎶 listened to <a href="{{scrobble.track.mb_link}}">{{scrobble.track.title}}</a> by <a href="{{scrobble.track.artist.mb_link}}">{{scrobble.track.artist}}</a></li> <div class="table-responsive">
{% endif %} <table class="table table-striped table-sm">
{% endfor %} <thead>
</ul> <tr>
<th scope="col">Time</th>
<th scope="col">Track</th>
<th scope="col">Artist</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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-watched" role="tabpanel" aria-labelledby="latest-watched-tab">
<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">Source</th>
</tr>
</thead>
<tbody>
{% for scrobble in video_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{% if scrobble.video.tv_series %}E{{scrobble.video.season_number}}S{{scrobble.video.season_number}} -{% endif %} {{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
<td>{{scrobble.source}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</main>
{% endblock %} {% endblock %}