Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72fded4097 | |||
| d5eea53a01 | |||
| a49eb31276 | |||
| c1e1160db3 | |||
| 0e17831724 | |||
| 045fad8552 | |||
| a09c6d6b92 | |||
| 3f8b29f5ee | |||
| 507b3aaaf2 | |||
| 879357473a | |||
| cc7d267494 | |||
| 685c99d023 | |||
| 69f596039d | |||
| cf55c9b464 | |||
| 8517212d0e | |||
| f435e60b80 | |||
| b51b189cd4 | |||
| 2a20d1212b | |||
| 83b6ba9cc3 |
21
poetry.lock
generated
21
poetry.lock
generated
@ -625,6 +625,14 @@ category = "dev"
|
||||
optional = false
|
||||
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]]
|
||||
name = "mypy"
|
||||
version = "0.961"
|
||||
@ -1369,7 +1377,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.8"
|
||||
content-hash = "6105971e3adba942edffa16bd54f5822cdcabcd1e55dfecfc67410cf486a1a71"
|
||||
content-hash = "f8d6b69cfb8ac53b7e03533e8fede68d323363bd73b9c7902d958dc433b1210e"
|
||||
|
||||
[metadata.files]
|
||||
amqp = [
|
||||
@ -1719,6 +1727,10 @@ mccabe = [
|
||||
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
|
||||
{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 = [
|
||||
{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"},
|
||||
@ -1769,6 +1781,13 @@ pbr = [
|
||||
{file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"},
|
||||
]
|
||||
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_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
|
||||
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.2.2"
|
||||
version = "0.4.3"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -27,6 +27,7 @@ django-markdownify = "^0.9.1"
|
||||
gunicorn = "^20.1.0"
|
||||
django-simple-history = "^3.1.1"
|
||||
whitenoise = "^6.3.0"
|
||||
musicbrainzngs = "^0.7.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Werkzeug = "2.0.3"
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>Untitled</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<!-- Place favicon.ico in the root directory -->
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<!--[if lt IE 8]>
|
||||
<p class="browserupgrade">
|
||||
You are using an <strong>outdated</strong> browser. Please
|
||||
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
|
||||
your experience.
|
||||
</p>
|
||||
<![endif]-->
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -9,6 +9,9 @@ class AlbumAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "year", "musicbrainz_id")
|
||||
list_filter = ("year",)
|
||||
ordering = ("name",)
|
||||
filter_horizontal = [
|
||||
'artists',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Artist)
|
||||
|
||||
@ -18,7 +18,7 @@ STARTING_DAY_OF_CURRENT_YEAR = NOW.date().replace(month=1, day=1)
|
||||
|
||||
|
||||
def scrobble_counts():
|
||||
finished_scrobbles_qs = Scrobble.objects.filter(in_progress=False)
|
||||
finished_scrobbles_qs = Scrobble.objects.filter(played_to_completion=True)
|
||||
data = {}
|
||||
data['today'] = finished_scrobbles_qs.filter(
|
||||
timestamp__gte=START_OF_TODAY
|
||||
@ -53,7 +53,7 @@ def week_of_scrobbles(media: str = 'tracks') -> dict[str, int]:
|
||||
.filter(
|
||||
timestamp__gte=start,
|
||||
timestamp__lte=end,
|
||||
in_progress=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
23
vrobbler/apps/music/migrations/0005_album_cover_image.py
Normal file
23
vrobbler/apps/music/migrations/0005_album_cover_image.py
Normal 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/'
|
||||
),
|
||||
),
|
||||
]
|
||||
20
vrobbler/apps/music/migrations/0006_album_artists.py
Normal file
20
vrobbler/apps/music/migrations/0006_album_artists.py
Normal 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'
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/music/migrations/0007_alter_album_artists.py
Normal file
18
vrobbler/apps/music/migrations/0007_alter_album_artists.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
17
vrobbler/apps/music/migrations/0008_alter_track_options.py
Normal file
17
vrobbler/apps/music/migrations/0008_alter_track_options.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0007_alter_album_artists'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='track',
|
||||
options={},
|
||||
),
|
||||
]
|
||||
@ -2,39 +2,25 @@ import logging
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import musicbrainzngs
|
||||
from django.apps.config import cached_property
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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, unique=True, **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):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
class Meta:
|
||||
unique_together=[['name', 'musicbrainz_id']]
|
||||
unique_together = [['name', 'musicbrainz_id']]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -44,20 +30,71 @@ class Artist(TimeStampedModel):
|
||||
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):
|
||||
if not self.musicbrainz_albumartist_id or not self.year:
|
||||
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):
|
||||
if not self.cover_image:
|
||||
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)
|
||||
except musicbrainzngs.ResponseError:
|
||||
logger.warning(f'No cover art found for {self.name}')
|
||||
self.cover_image = 'default-image-replace-me'
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def mb_link(self):
|
||||
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
|
||||
|
||||
|
||||
class Track(ScrobblableMixin):
|
||||
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)
|
||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||
musicbrainz_id = models.CharField(max_length=255, 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):
|
||||
return f"{self.title} by {self.artist}"
|
||||
@ -83,9 +120,10 @@ class Track(TimeStampedModel):
|
||||
'musicbrainz_id'
|
||||
):
|
||||
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
|
||||
|
||||
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
|
||||
if artist_created:
|
||||
logger.debug(f"Created new album {artist}")
|
||||
@ -98,6 +136,10 @@ class Track(TimeStampedModel):
|
||||
else:
|
||||
logger.debug(f"Found album {album}")
|
||||
|
||||
album.fix_metadata()
|
||||
if not album.cover_image:
|
||||
album.fetch_artwork()
|
||||
|
||||
track_dict['album_id'] = getattr(album, "id", None)
|
||||
track_dict['artist_id'] = artist.id
|
||||
|
||||
|
||||
0
vrobbler/apps/podcasts/__init__.py
Normal file
0
vrobbler/apps/podcasts/__init__.py
Normal file
33
vrobbler/apps/podcasts/admin.py
Normal file
33
vrobbler/apps/podcasts/admin.py
Normal 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",)
|
||||
5
vrobbler/apps/podcasts/apps.py
Normal file
5
vrobbler/apps/podcasts/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PodcastsConfig(AppConfig):
|
||||
name = 'podcasts'
|
||||
158
vrobbler/apps/podcasts/migrations/0001_initial.py
Normal file
158
vrobbler/apps/podcasts/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py
Normal file
18
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py
Normal 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),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/podcasts/migrations/0004_episode_number.py
Normal file
18
vrobbler/apps/podcasts/migrations/0004_episode_number.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0004_episode_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='episode',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='episode',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/podcasts/migrations/__init__.py
Normal file
0
vrobbler/apps/podcasts/migrations/__init__.py
Normal file
85
vrobbler/apps/podcasts/models.py
Normal file
85
vrobbler/apps/podcasts/models.py
Normal 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
|
||||
@ -3,18 +3,37 @@ from django.contrib import admin
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
@admin.register(Scrobble)
|
||||
class ScrobbleAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "timestamp"
|
||||
list_display = (
|
||||
"timestamp",
|
||||
"video",
|
||||
"track",
|
||||
"media_name",
|
||||
"media_type",
|
||||
"playback_percent",
|
||||
"source",
|
||||
"playback_position",
|
||||
"in_progress",
|
||||
"is_paused",
|
||||
"played_to_completion",
|
||||
)
|
||||
list_filter = ("in_progress", "source", "track__artist")
|
||||
list_filter = ("is_paused", "in_progress", "source", "track__artist")
|
||||
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"
|
||||
|
||||
def playback_percent(self, obj):
|
||||
return obj.percent_played
|
||||
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
17
vrobbler/apps/scrobbles/mixins.py
Normal file
17
vrobbler/apps/scrobbles/mixins.py
Normal 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
|
||||
@ -8,6 +8,8 @@ from django.db import models
|
||||
from django.utils import timezone
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from music.models import Track
|
||||
from podcasts.models import Episode
|
||||
from scrobbles.utils import check_scrobble_for_finish
|
||||
from videos.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -22,6 +24,9 @@ TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
|
||||
class Scrobble(TimeStampedModel):
|
||||
video = models.ForeignKey(Video, 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, blank=True, null=True, on_delete=models.DO_NOTHING
|
||||
)
|
||||
@ -37,58 +42,38 @@ class Scrobble(TimeStampedModel):
|
||||
|
||||
@property
|
||||
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 self.source != 'Mopidy'
|
||||
):
|
||||
return int(
|
||||
(self.playback_position_ticks / self.media_run_time_ticks)
|
||||
(self.playback_position_ticks / self.media_obj.run_time_ticks)
|
||||
* 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()
|
||||
playback_duration = (now - self.created).seconds
|
||||
if playback_duration and self.track.run_time:
|
||||
return int((playback_duration / int(self.track.run_time)) * 100)
|
||||
if playback_duration and self.media_obj.run_time:
|
||||
return int(
|
||||
(playback_duration / int(self.media_obj.run_time)) * 100
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
@property
|
||||
def media_run_time_ticks(self) -> int:
|
||||
def media_obj(self):
|
||||
media_obj = None
|
||||
if self.video:
|
||||
return self.video.run_time_ticks
|
||||
media_obj = self.video
|
||||
if self.track:
|
||||
return self.track.run_time_ticks
|
||||
# this is hacky, but want to avoid divide by zero
|
||||
return 1
|
||||
|
||||
def is_stale(self, backoff, wait_period) -> bool:
|
||||
scrobble_is_stale = self.in_progress and self.modified > wait_period
|
||||
|
||||
# Check if found in progress scrobble is more than a day old
|
||||
if scrobble_is_stale:
|
||||
logger.info(
|
||||
'Found a in-progress scrobble for this item more than a day old, creating a new scrobble'
|
||||
)
|
||||
delete_stale_scrobbles = getattr(
|
||||
settings, "DELETE_STALE_SCROBBLES", True
|
||||
)
|
||||
|
||||
if delete_stale_scrobbles:
|
||||
logger.info(
|
||||
'Deleting {scrobble} that has been in-progress too long'
|
||||
)
|
||||
self.delete()
|
||||
|
||||
return scrobble_is_stale
|
||||
media_obj = self.track
|
||||
if self.podcast_episode:
|
||||
media_obj = self.podcast_episode
|
||||
return media_obj
|
||||
|
||||
def __str__(self):
|
||||
media = None
|
||||
if self.video:
|
||||
media = self.video
|
||||
if self.track:
|
||||
media = self.track
|
||||
|
||||
return (
|
||||
f"Scrobble of {media} {self.timestamp.year}-{self.timestamp.month}"
|
||||
)
|
||||
return f"Scrobble of {self.media_obj} {self.timestamp.year}-{self.timestamp.month}"
|
||||
|
||||
@classmethod
|
||||
def create_or_update_for_video(
|
||||
@ -134,6 +119,28 @@ class Scrobble(TimeStampedModel):
|
||||
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
|
||||
def update_or_create(
|
||||
cls,
|
||||
@ -144,23 +151,31 @@ class Scrobble(TimeStampedModel):
|
||||
) -> Optional["Scrobble"]:
|
||||
|
||||
# Status is a field we get from Mopidy, which refuses to poll us
|
||||
mopidy_status = scrobble_data.pop('status', None)
|
||||
scrobble_is_stale = False
|
||||
scrobble_status = scrobble_data.pop('mopidy_status', None)
|
||||
if not scrobble_status:
|
||||
scrobble_status = scrobble_data.pop('jellyfin_status', None)
|
||||
if not scrobble_status:
|
||||
logger.warning(
|
||||
f"No status update found in message, not scrobbling"
|
||||
)
|
||||
return
|
||||
|
||||
if mopidy_status == "stopped":
|
||||
logger.info(f"Mopidy sent a message to stop {scrobble}")
|
||||
if not scrobble:
|
||||
logger.warning(
|
||||
'Mopidy sent us a stopped message, without ever starting'
|
||||
)
|
||||
return
|
||||
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
|
||||
if scrobble:
|
||||
scrobble.update_ticks(scrobble_data)
|
||||
|
||||
# Mopidy finished a play, scrobble away
|
||||
scrobble.in_progress = False
|
||||
scrobble.save(update_fields=['in_progress'])
|
||||
return scrobble
|
||||
# On stop, stop progress and send it to the check for completion
|
||||
if scrobble_status == "stopped":
|
||||
return scrobble.stop()
|
||||
|
||||
if scrobble and not mopidy_status:
|
||||
# On pause, set is_paused and stop scrobbling
|
||||
if scrobble_status == "paused":
|
||||
return scrobble.pause()
|
||||
|
||||
if scrobble_status == "resumed":
|
||||
return scrobble.resume()
|
||||
|
||||
# We're not changing the scrobble, but we don't want to walk over an existing one
|
||||
scrobble_is_finished = (
|
||||
not scrobble.in_progress and scrobble.modified < backoff
|
||||
)
|
||||
@ -170,9 +185,7 @@ class Scrobble(TimeStampedModel):
|
||||
)
|
||||
return
|
||||
|
||||
scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
|
||||
|
||||
if (not scrobble or scrobble_is_stale) or mopidy_status:
|
||||
if not scrobble:
|
||||
# If we default this to "" we can probably remove this
|
||||
scrobble_data['scrobble_log'] = ""
|
||||
scrobble = cls.objects.create(
|
||||
@ -185,16 +198,40 @@ class Scrobble(TimeStampedModel):
|
||||
|
||||
# If we hit our completion threshold, save it and get ready
|
||||
# to scrobble again if we re-watch this.
|
||||
if scrobble.percent_played >= getattr(
|
||||
settings, "PERCENT_FOR_COMPLETION", 95
|
||||
):
|
||||
scrobble.in_progress = False
|
||||
scrobble.playback_position_ticks = scrobble.media_run_time_ticks
|
||||
scrobble.save()
|
||||
|
||||
if scrobble.percent_played % 5 == 0:
|
||||
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
|
||||
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
|
||||
scrobble.save(update_fields=['scrobble_log'])
|
||||
scrobble = check_scrobble_for_finish(scrobble)
|
||||
|
||||
return scrobble
|
||||
|
||||
def stop(self):
|
||||
if not self.in_progress:
|
||||
logger.warning("Scrobble already stopped")
|
||||
return
|
||||
self.in_progress = False
|
||||
self.save(update_fields=['in_progress'])
|
||||
return check_scrobble_for_finish(self)
|
||||
|
||||
def pause(self):
|
||||
if self.is_paused:
|
||||
logger.warning("Scrobble already paused")
|
||||
return
|
||||
self.is_paused = True
|
||||
self.save(update_fields=["is_paused"])
|
||||
return check_scrobble_for_finish(self)
|
||||
|
||||
def resume(self):
|
||||
if self.is_paused or not self.in_progress:
|
||||
self.is_paused = False
|
||||
self.in_progress = True
|
||||
return self.save(update_fields=["is_paused", "in_progress"])
|
||||
return self
|
||||
|
||||
def update_ticks(self, data):
|
||||
self.playback_position_ticks = data.get("playback_position_ticks")
|
||||
self.playback_position = data.get("playback_position")
|
||||
logger.debug(
|
||||
f"Updating scrobble ticks to {self.playback_position_ticks}"
|
||||
)
|
||||
self.save(
|
||||
update_fields=['playback_position_ticks', 'playback_position']
|
||||
)
|
||||
return self
|
||||
|
||||
178
vrobbler/apps/scrobbles/scrobblers.py
Normal file
178
vrobbler/apps/scrobbles/scrobblers.py
Normal file
@ -0,0 +1,178 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from dateutil.parser import parse
|
||||
from django.utils import timezone
|
||||
from music.constants import JELLYFIN_POST_KEYS
|
||||
from music.models import Track
|
||||
from podcasts.models import Episode
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
|
||||
from videos.models import Video
|
||||
|
||||
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",
|
||||
"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",
|
||||
"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
|
||||
|
||||
|
||||
def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
|
||||
jellyfin_status = "resumed"
|
||||
if data_dict.get("IsPaused"):
|
||||
jellyfin_status = "paused"
|
||||
if data_dict.get("PlayedToCompletion"):
|
||||
jellyfin_status = "stopped"
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"timestamp": parse(data_dict.get("UtcTimestamp")),
|
||||
"playback_position_ticks": data_dict.get("PlaybackPositionTicks")
|
||||
// 10000,
|
||||
"playback_position": convert_to_seconds(
|
||||
data_dict.get("PlaybackPosition")
|
||||
),
|
||||
"source": "Jellyfin",
|
||||
"source_id": data_dict.get('MediaSourceId'),
|
||||
"jellyfin_status": jellyfin_status,
|
||||
}
|
||||
|
||||
|
||||
def jellyfin_scrobble_track(
|
||||
data_dict: dict, user_id: Optional[int]
|
||||
) -> Optional[Scrobble]:
|
||||
if not data_dict.get("Provider_musicbrainztrack", None):
|
||||
logger.error(
|
||||
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
return
|
||||
|
||||
artist_dict = {
|
||||
'name': data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"], None),
|
||||
'musicbrainz_id': data_dict.get(
|
||||
JELLYFIN_POST_KEYS["ARTIST_MB_ID"], None
|
||||
),
|
||||
}
|
||||
|
||||
album_dict = {
|
||||
"name": data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"], None),
|
||||
"musicbrainz_id": data_dict.get(JELLYFIN_POST_KEYS['ALBUM_MB_ID']),
|
||||
}
|
||||
|
||||
# Convert ticks from Jellyfin from microseconds to nanoseconds
|
||||
# Ain't nobody got time for nanoseconds
|
||||
track_dict = {
|
||||
"title": data_dict.get("Name", ""),
|
||||
"run_time_ticks": data_dict.get(
|
||||
JELLYFIN_POST_KEYS["RUN_TIME_TICKS"], None
|
||||
)
|
||||
// 10000,
|
||||
"run_time": convert_to_seconds(
|
||||
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"], None)
|
||||
),
|
||||
}
|
||||
track = Track.find_or_create(artist_dict, album_dict, track_dict)
|
||||
|
||||
# Prefer Mopidy MD IDs to Jellyfin, so skip if we already have one
|
||||
if not track.musicbrainz_id:
|
||||
track.musicbrainz_id = data_dict.get(
|
||||
JELLYFIN_POST_KEYS["TRACK_MB_ID"], None
|
||||
)
|
||||
track.save()
|
||||
|
||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
||||
|
||||
return Scrobble.create_or_update_for_track(track, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
||||
if not data_dict.get("Provider_imdb", None):
|
||||
logger.error(
|
||||
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
return
|
||||
video = Video.find_or_create(data_dict)
|
||||
|
||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
||||
|
||||
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
|
||||
@ -1,3 +1,13 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
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:
|
||||
"""Jellyfin sends run time as 00:00:00 string. We want the run time to
|
||||
actually be in seconds so we'll convert it"""
|
||||
@ -5,3 +15,70 @@ def convert_to_seconds(run_time: str) -> int:
|
||||
run_time_list = run_time.split(":")
|
||||
run_time = (int(run_time_list[1]) * 60) + int(run_time_list[2])
|
||||
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", 95)
|
||||
if scrobble.video:
|
||||
completion_percent = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
|
||||
if scrobble.podcast_episode:
|
||||
completion_percent = getattr(
|
||||
settings, "PODCAST_COMPLETION_PERCENT", 25
|
||||
)
|
||||
if scrobble.percent_played >= completion_percent:
|
||||
scrobble.in_progress = False
|
||||
scrobble.is_paused = False
|
||||
scrobble.played_to_completion = True
|
||||
scrobble.save(
|
||||
update_fields=["in_progress", "is_paused", "played_to_completion"]
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@ -18,9 +18,16 @@ from scrobbles.constants import (
|
||||
JELLYFIN_VIDEO_ITEM_TYPES,
|
||||
)
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.scrobblers import (
|
||||
jellyfin_scrobble_track,
|
||||
jellyfin_scrobble_video,
|
||||
mopidy_scrobble_podcast,
|
||||
mopidy_scrobble_track,
|
||||
)
|
||||
from scrobbles.serializers import ScrobbleSerializer
|
||||
from scrobbles.utils import convert_to_seconds
|
||||
from videos.models import Video
|
||||
|
||||
from vrobbler.apps.music.aggregators import (
|
||||
scrobble_counts,
|
||||
top_artists,
|
||||
@ -54,11 +61,14 @@ class RecentScrobbleList(ListView):
|
||||
data['now_playing_list'] = Scrobble.objects.filter(
|
||||
in_progress=True,
|
||||
is_paused=False,
|
||||
timestamp__gte=last_eight_minutes,
|
||||
modified__gte=last_eight_minutes,
|
||||
timestamp__lte=now,
|
||||
)
|
||||
data['video_scrobble_list'] = Scrobble.objects.filter(
|
||||
video__isnull=False, in_progress=False
|
||||
video__isnull=False, played_to_completion=True
|
||||
).order_by('-timestamp')[:10]
|
||||
data['podcast_scrobble_list'] = Scrobble.objects.filter(
|
||||
podcast_episode__isnull=False, played_to_completion=True
|
||||
).order_by('-timestamp')[:10]
|
||||
data['top_daily_tracks'] = top_tracks()
|
||||
data['top_weekly_tracks'] = top_tracks(filter='week')
|
||||
@ -75,7 +85,7 @@ class RecentScrobbleList(ListView):
|
||||
def get_queryset(self):
|
||||
return Scrobble.objects.filter(
|
||||
track__isnull=False, in_progress=False
|
||||
).order_by('-timestamp')[:25]
|
||||
).order_by('-timestamp')[:15]
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@ -97,79 +107,14 @@ def jellyfin_websocket(request):
|
||||
json_data = json.dumps(data_dict, indent=4)
|
||||
logger.debug(f"{json_data}")
|
||||
|
||||
scrobble = None
|
||||
media_type = data_dict.get("ItemType", "")
|
||||
|
||||
track = None
|
||||
video = None
|
||||
if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
|
||||
if not data_dict.get("Provider_musicbrainztrack", None):
|
||||
logger.error(
|
||||
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
artist_dict = {
|
||||
'name': data_dict.get(KEYS["ARTIST_NAME"], None),
|
||||
'musicbrainz_id': data_dict.get(KEYS["ARTIST_MB_ID"], None),
|
||||
}
|
||||
|
||||
album_dict = {
|
||||
"name": data_dict.get(KEYS["ALBUM_NAME"], None),
|
||||
"year": data_dict.get(KEYS["YEAR"], ""),
|
||||
"musicbrainz_id": data_dict.get(KEYS['ALBUM_MB_ID']),
|
||||
"musicbrainz_releasegroup_id": data_dict.get(
|
||||
KEYS["RELEASEGROUP_MB_ID"]
|
||||
),
|
||||
"musicbrainz_albumartist_id": data_dict.get(KEYS["ARTIST_MB_ID"]),
|
||||
}
|
||||
|
||||
# Convert ticks from Jellyfin from microseconds to nanoseconds
|
||||
# Ain't nobody got time for nanoseconds
|
||||
track_dict = {
|
||||
"title": data_dict.get("Name", ""),
|
||||
"run_time_ticks": data_dict.get(KEYS["RUN_TIME_TICKS"], None)
|
||||
// 10000,
|
||||
"run_time": convert_to_seconds(
|
||||
data_dict.get(KEYS["RUN_TIME"], None)
|
||||
),
|
||||
}
|
||||
track = Track.find_or_create(artist_dict, album_dict, track_dict)
|
||||
scrobble = jellyfin_scrobble_track(data_dict, request.user.id)
|
||||
|
||||
if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
|
||||
if not data_dict.get("Provider_imdb", None):
|
||||
logger.error(
|
||||
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
video = Video.find_or_create(data_dict)
|
||||
|
||||
# Now we run off a scrobble
|
||||
jellyfin_data = {
|
||||
"user_id": request.user.id,
|
||||
"timestamp": parse(data_dict.get("UtcTimestamp")),
|
||||
"playback_position_ticks": data_dict.get("PlaybackPositionTicks")
|
||||
// 10000,
|
||||
"playback_position": convert_to_seconds(
|
||||
data_dict.get("PlaybackPosition")
|
||||
),
|
||||
"source": "Jellyfin",
|
||||
"source_id": data_dict.get('MediaSourceId'),
|
||||
"is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
|
||||
}
|
||||
|
||||
scrobble = None
|
||||
if video:
|
||||
scrobble = Scrobble.create_or_update_for_video(
|
||||
video, request.user.id, jellyfin_data
|
||||
)
|
||||
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(
|
||||
track, request.user.id, jellyfin_data
|
||||
)
|
||||
scrobble = jellyfin_scrobble_video(data_dict, request.user.id)
|
||||
|
||||
if not scrobble:
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@ -187,41 +132,12 @@ def mopidy_websocket(request):
|
||||
# For making things easier to build new input processors
|
||||
if getattr(settings, "DUMP_REQUEST_DATA", False):
|
||||
json_data = json.dumps(data_dict, indent=4)
|
||||
logger.debug(f"{json_data}")
|
||||
|
||||
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": 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 'podcast' in data_dict.get('mopidy_uri'):
|
||||
scrobble = mopidy_scrobble_podcast(data_dict, request.user.id)
|
||||
else:
|
||||
scrobble = mopidy_scrobble_track(data_dict, request.user.id)
|
||||
|
||||
if not scrobble:
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
18
vrobbler/apps/videos/migrations/0006_alter_video_year.py
Normal file
18
vrobbler/apps/videos/migrations/0006_alter_video_year.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('videos', '0005_alter_video_options_alter_video_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='video',
|
||||
name='year',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -7,6 +7,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.utils import convert_to_seconds
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
@ -30,15 +31,12 @@ class Series(TimeStampedModel):
|
||||
verbose_name_plural = "series"
|
||||
|
||||
|
||||
class Video(TimeStampedModel):
|
||||
class Video(ScrobblableMixin):
|
||||
class VideoType(models.TextChoices):
|
||||
UNKNOWN = 'U', _('Unknown')
|
||||
TV_EPISODE = 'E', _('TV Episode')
|
||||
MOVIE = 'M', _('Movie')
|
||||
|
||||
# General fields
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
title = models.CharField(max_length=255, **BNULL)
|
||||
video_type = models.CharField(
|
||||
max_length=1,
|
||||
choices=VideoType.choices,
|
||||
@ -46,9 +44,7 @@ class Video(TimeStampedModel):
|
||||
)
|
||||
overview = 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(**BNULL)
|
||||
|
||||
# TV show specific fields
|
||||
tv_series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
|
||||
@ -84,13 +80,9 @@ class Video(TimeStampedModel):
|
||||
"title": data_dict.get("Name", ""),
|
||||
"imdb_id": data_dict.get("Provider_imdb", None),
|
||||
"video_type": Video.VideoType.MOVIE,
|
||||
"year": data_dict.get("Year", ""),
|
||||
"overview": data_dict.get("Overview", None),
|
||||
"tagline": data_dict.get("Tagline", None),
|
||||
"run_time_ticks": data_dict.get("RunTimeTicks", 0) // 10000,
|
||||
"run_time": convert_to_seconds(data_dict.get("RunTime", "")),
|
||||
}
|
||||
|
||||
series = None
|
||||
if data_dict.get("ItemType", "") == "Episode":
|
||||
series_name = data_dict.get("SeriesName", "")
|
||||
series, series_created = Series.objects.get_or_create(
|
||||
@ -101,16 +93,29 @@ class Video(TimeStampedModel):
|
||||
else:
|
||||
logger.debug(f"Found series {series}")
|
||||
video_dict['video_type'] = Video.VideoType.TV_EPISODE
|
||||
video_dict["tv_series_id"] = series.id
|
||||
video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
|
||||
video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None)
|
||||
video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
|
||||
video_dict["season_number"] = data_dict.get("SeasonNumber", "")
|
||||
|
||||
video, created = cls.objects.get_or_create(**video_dict)
|
||||
|
||||
video_extra_dict = {
|
||||
"year": data_dict.get("Year", ""),
|
||||
"overview": data_dict.get("Overview", None),
|
||||
"tagline": data_dict.get("Tagline", None),
|
||||
"run_time_ticks": data_dict.get("RunTimeTicks", 0) // 10000,
|
||||
"run_time": convert_to_seconds(data_dict.get("RunTime", "")),
|
||||
"tvdb_id": data_dict.get("Provider_tvdb", None),
|
||||
"tvrage_id": data_dict.get("Provider_tvrage", None),
|
||||
"episode_number": data_dict.get("EpisodeNumber", ""),
|
||||
"season_number": data_dict.get("SeasonNumber", ""),
|
||||
}
|
||||
|
||||
if series:
|
||||
video_extra_dict["tv_series_id"] = series.id
|
||||
|
||||
if created:
|
||||
logger.debug(f"Created new video: {video}")
|
||||
for key, value in video_extra_dict.items():
|
||||
setattr(video, key, value)
|
||||
video.save()
|
||||
else:
|
||||
logger.debug(f"Found video {video}")
|
||||
|
||||
|
||||
@ -37,6 +37,11 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
|
||||
"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?
|
||||
DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
|
||||
|
||||
@ -44,7 +49,7 @@ DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
|
||||
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
|
||||
|
||||
VIDEO_BACKOFF_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 15)
|
||||
MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 5)
|
||||
MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 1)
|
||||
|
||||
# If you stop waching or listening to a track, how long should we wait before we
|
||||
# give up on the old scrobble and start a new one? This could also be considered
|
||||
@ -86,6 +91,7 @@ INSTALLED_APPS = [
|
||||
"scrobbles",
|
||||
"videos",
|
||||
"music",
|
||||
"podcasts",
|
||||
"rest_framework",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
|
||||
@ -194,27 +194,17 @@
|
||||
<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/>
|
||||
{{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 %}
|
||||
{% if scrobble.video.tv_series %}<em>{{scrobble.video.tv_series }}</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>
|
||||
{% endif %}
|
||||
<hr/>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@ -223,25 +213,31 @@
|
||||
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
<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="#">
|
||||
<a class="nav-link" href="/artists/">
|
||||
<span data-feather="user"></span>
|
||||
Artists
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">
|
||||
<a class="nav-link" href="/movies/">
|
||||
<span data-feather="film"></span>
|
||||
Movies
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">
|
||||
<a class="nav-link" href="/series/">
|
||||
<span data-feather="tv"></span>
|
||||
Series
|
||||
</a>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
|
||||
{% block content %}
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Dashboard</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
@ -18,108 +18,156 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas class="my-4 w-100" id="myChart" width="900" height="380"></canvas>
|
||||
<canvas class="my-4 w-100" id="myChart" width="900" height="220"></canvas>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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 active" 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 class="tab-pane fade show" id="tracks-week" role="tabpanel" aria-labelledby="tracks-week-tab">
|
||||
<h2>Top tracks this week</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Track</th>
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-lg">
|
||||
<h2>Top tracks this week</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<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="#latest-listened" type="button" role="tab" aria-controls="home" aria-selected="true">Latest Listened</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<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 class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted" type="button" role="tab" aria-controls="profile" aria-selected="false">Latest Podcasted</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="myTabContent2">
|
||||
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel" aria-labelledby="latest-listened-tab">
|
||||
<h2>Latest listened</h2>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<h2>Latest listened</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>
|
||||
</div>
|
||||
|
||||
<div class="col-lg">
|
||||
<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 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>
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel" aria-labelledby="latest-podcasted-tab">
|
||||
<h2>Latest Podcasted</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Podcast</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in podcast_scrobble_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
<td>{{scrobble.podcast_episode.title}}</td>
|
||||
<td>{{scrobble.podcast_episode.podcast}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Movies</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle">
|
||||
<span data-feather="calendar"></span>
|
||||
This week
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Movies</h2>
|
||||
<div class="container">
|
||||
|
||||
<ul>
|
||||
{% for movie in object_list %}
|
||||
<li>{{movie}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
22
vrobbler/templates/videos/series_list.html
Normal file
22
vrobbler/templates/videos/series_list.html
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Series</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<ul>
|
||||
{% for movie in object_list %}
|
||||
<li>{{movie}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user