Compare commits

..

8 Commits

Author SHA1 Message Date
56e5728245 Bump version to 0.11.2 2023-02-27 10:30:44 -05:00
6ff170e169 Quite a few bugs 2023-02-27 10:30:20 -05:00
86d1cf0d65 Bump version to 0.11.1 2023-02-26 23:27:17 -05:00
a0101bf1ae Add first pass at AudioDB fetching 2023-02-26 23:26:51 -05:00
457afdc9ef Big fix to aggregation 2023-02-26 22:21:49 -05:00
d5bf6440b0 Bump version to 0.11.0 2023-02-26 02:00:37 -05:00
803ed7d8d7 Add base chart view 2023-02-26 02:00:15 -05:00
93c4dd3d3b Fix aggregation 2023-02-26 01:56:11 -05:00
28 changed files with 974 additions and 429 deletions

View File

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

View File

@ -55,7 +55,9 @@ def scrobble_counts(user=None):
return data return data
def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]: def week_of_scrobbles(
user=None, start=None, media: str = 'tracks'
) -> dict[str, int]:
now = timezone.now() now = timezone.now()
user_filter = Q() user_filter = Q()
@ -63,9 +65,8 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
now = now_user_timezone(user.profile) now = now_user_timezone(user.profile)
user_filter = Q(user=user) user_filter = Q(user=user)
start_of_today = datetime.combine( if not start:
now.date(), datetime.min.time(), now.tzinfo start = datetime.combine(now.date(), datetime.min.time(), now.tzinfo)
)
scrobble_day_dict = {} scrobble_day_dict = {}
base_qs = Scrobble.objects.filter(user_filter, played_to_completion=True) base_qs = Scrobble.objects.filter(user_filter, played_to_completion=True)
@ -77,13 +78,13 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
media_filter = Q(video__video_type=Video.VideoType.TV_EPISODE) media_filter = Q(video__video_type=Video.VideoType.TV_EPISODE)
for day in range(6, -1, -1): for day in range(6, -1, -1):
start = start_of_today - timedelta(days=day) start_day = start - timedelta(days=day)
end = datetime.combine(start, datetime.max.time(), now.tzinfo) end = datetime.combine(start_day, datetime.max.time(), now.tzinfo)
day_of_week = start.strftime('%A') day_of_week = start_day.strftime('%A')
scrobble_day_dict[day_of_week] = base_qs.filter( scrobble_day_dict[day_of_week] = base_qs.filter(
media_filter, media_filter,
timestamp__gte=start, timestamp__gte=start_day,
timestamp__lte=end, timestamp__lte=end,
played_to_completion=True, played_to_completion=True,
).count() ).count()
@ -92,7 +93,7 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
def top_tracks( def top_tracks(
user: "User", filter: str = "today", limit: int = 15 user: "User", filter: str = "today", limit: int = 30
) -> List["Track"]: ) -> List["Track"]:
now = timezone.now() now = timezone.now()
@ -108,6 +109,8 @@ def top_tracks(
starting_day_of_current_month = now.date().replace(day=1) starting_day_of_current_month = now.date().replace(day=1)
starting_day_of_current_year = now.date().replace(month=1, day=1) starting_day_of_current_year = now.date().replace(month=1, day=1)
time_filter = Q()
if filter == "today":
time_filter = Q(scrobble__timestamp__gte=start_of_today) time_filter = Q(scrobble__timestamp__gte=start_of_today)
if filter == "week": if filter == "week":
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_week) time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_week)
@ -118,7 +121,13 @@ def top_tracks(
return ( return (
Track.objects.filter(time_filter) Track.objects.filter(time_filter)
.annotate(num_scrobbles=Count("scrobble", distinct=True)) .annotate(
num_scrobbles=Count(
"scrobble",
filter=Q(scrobble__played_to_completion=True),
distinct=True,
)
)
.order_by("-num_scrobbles")[:limit] .order_by("-num_scrobbles")[:limit]
) )
@ -142,8 +151,14 @@ def top_artists(
return ( return (
Artist.objects.filter(time_filter) Artist.objects.filter(time_filter)
.annotate(num_scrobbles=Count("track__scrobble", distinct=True)) .annotate(
.order_by("-num_scrobbles")[:limit] num_scrobbles=Count(
"track__scrobble",
filter=Q(track__scrobble__played_to_completion=True),
distinct=True,
)
)
.order_by("-num_scrobbles")
) )

View File

@ -0,0 +1,28 @@
# Generated by Django 4.1.5 on 2023-02-27 03:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0009_alter_track_musicbrainz_id_and_more'),
]
operations = [
migrations.AddField(
model_name='artist',
name='biography',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='artist',
name='theaudiodb_genre',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='artist',
name='theaudiodb_mood',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.1.5 on 2023-02-27 04:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0010_artist_biography_artist_theaudiodb_genre_and_more'),
]
operations = [
migrations.AddField(
model_name='artist',
name='thumbnail',
field=models.ImageField(
blank=True, null=True, upload_to='artist/'
),
),
]

View File

@ -1,15 +1,18 @@
import logging import logging
from tempfile import NamedTemporaryFile
from typing import Dict, Optional from typing import Dict, Optional
from urllib.request import urlopen
from uuid import uuid4 from uuid import uuid4
import musicbrainzngs import musicbrainzngs
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile, File
from django.db import models from django.db import models
from django.urls import reverse 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.mixins import ScrobblableMixin from scrobbles.mixins import ScrobblableMixin
from scrobbles.theaudiodb import lookup_artist_from_tadb
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -18,7 +21,11 @@ BNULL = {"blank": True, "null": True}
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)
biography = models.TextField(**BNULL)
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL) musicbrainz_id = models.CharField(max_length=255, **BNULL)
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
class Meta: class Meta:
unique_together = [['name', 'musicbrainz_id']] unique_together = [['name', 'musicbrainz_id']]
@ -53,6 +60,22 @@ class Artist(TimeStampedModel):
return ChartRecord.objects.filter(track__artist=self).order_by('-year') return ChartRecord.objects.filter(track__artist=self).order_by('-year')
def fix_metadata(self):
tadb_info = lookup_artist_from_tadb(self.name)
if not tadb_info:
logger.warn(f"No response from TADB for artist {self.name}")
return
self.biography = tadb_info['biography']
self.theaudiodb_genre = tadb_info['genre']
self.theaudiodb_mood = tadb_info['mood']
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(urlopen(tadb_info['thumb_url']).read())
img_temp.flush()
img_filename = f"{self.name}_{self.uuid}.jpg"
self.thumbnail.save(img_filename, File(img_temp))
class Album(TimeStampedModel): class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)

View File

@ -1,6 +1,8 @@
import logging import logging
import re import re
from musicbrainzngs.caa import musicbrainz
from scrobbles.musicbrainz import ( from scrobbles.musicbrainz import (
lookup_album_dict_from_mb, lookup_album_dict_from_mb,
lookup_artist_from_mb, lookup_artist_from_mb,
@ -21,20 +23,20 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip() name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
if 'featuring' in name.lower(): if 'featuring' in name.lower():
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip() name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
if '&' in name.lower():
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
artist_dict = lookup_artist_from_mb(name) artist_dict = lookup_artist_from_mb(name)
mbid = mbid or artist_dict['id'] mbid = mbid or artist_dict['id']
logger.debug(f'Looking up artist {name} and mbid: {mbid}') logger.debug(f'Looking up artist {name} and mbid: {mbid}')
artist, created = Artist.objects.get_or_create( artist = Artist.objects.filter(musicbrainz_id=mbid).first()
name=name, musicbrainz_id=mbid if not artist:
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
logger.debug(
f"Created artist {artist.name} ({artist.musicbrainz_id}) "
) )
artist.fix_metadata()
logger.debug(f"Cleaning artist {name} with {artist_dict['name']}")
# Clean up bad names in our DB with MB names
# if artist.name != artist_dict["name"]:
# artist.name = artist_dict["name"]
# artist.save(update_fields=["name"])
return artist return artist
@ -78,27 +80,22 @@ def get_or_create_album(name: str, artist: Artist, mbid: str = None) -> Album:
def get_or_create_track( def get_or_create_track(
title: str, title: str,
mbid: str,
artist: Artist, artist: Artist,
album: Album, album: Album,
mbid: str = None,
run_time=None, run_time=None,
run_time_ticks=None, run_time_ticks=None,
) -> Track: ) -> Track:
track = None track = None
if mbid:
track = Track.objects.filter(
musicbrainz_id=mbid,
).first()
if not track:
track = Track.objects.filter(
title=title, artist=artist, album=album
).first()
if not mbid: if not mbid:
mbid = lookup_track_from_mb( mbid = lookup_track_from_mb(
title, artist.musicbrainz_id, album.musicbrainz_id title,
artist.musicbrainz_id,
album.musicbrainz_id,
)['id'] )['id']
track = Track.objects.filter(musicbrainz_id=mbid).first()
if not track: if not track:
track = Track.objects.create( track = Track.objects.create(
title=title, title=title,

View File

@ -1,3 +1,4 @@
from django.db.models import Count
from django.views import generic from django.views import generic
from music.models import Track, Artist, Album from music.models import Track, Artist, Album
from scrobbles.stats import get_scrobble_count_qs from scrobbles.stats import get_scrobble_count_qs

View File

@ -47,6 +47,7 @@ class ChartRecordAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"user", "user",
"rank", "rank",
"count",
"year", "year",
"week", "week",
"month", "month",

View File

@ -0,0 +1,17 @@
import pytz
from django.utils import timezone
from scrobbles.models import Scrobble
def now_playing(request):
user = request.user
now = timezone.now()
if not user.is_authenticated:
return {}
return {
'now_playing_list': Scrobble.objects.filter(
in_progress=True,
is_paused=False,
user=user,
)
}

View File

@ -0,0 +1,30 @@
# Generated by Django 4.1.5 on 2023-02-25 00:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0022_scrobble_book_pages_read'),
]
operations = [
migrations.AlterModelOptions(
name='audioscrobblertsvimport',
options={'verbose_name': 'AudioScrobbler TSV Import'},
),
migrations.AlterModelOptions(
name='koreaderimport',
options={'verbose_name': 'KOReader Import'},
),
migrations.AlterModelOptions(
name='lastfmimport',
options={'verbose_name': 'Last.FM Import'},
),
migrations.AddField(
model_name='chartrecord',
name='count',
field=models.IntegerField(default=0),
),
]

View File

@ -2,19 +2,21 @@ import calendar
import logging import logging
from uuid import uuid4 from uuid import uuid4
from books.models import Book
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.urls import reverse
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 Artist, Track from music.models import Artist, Track
from books.models import Book
from podcasts.models import Episode from podcasts.models import Episode
from profiles.utils import now_user_timezone
from scrobbles.lastfm import LastFM from scrobbles.lastfm import LastFM
from scrobbles.utils import check_scrobble_for_finish from scrobbles.utils import check_scrobble_for_finish
from sports.models import SportEvent from sports.models import SportEvent
from videos.models import Series, Video from videos.models import Series, Video
from vrobbler.apps.scrobbles.stats import build_charts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -34,6 +36,24 @@ class BaseFileImportMixin(TimeStampedModel):
def __str__(self): def __str__(self):
return f"Scrobble import {self.id}" return f"Scrobble import {self.id}"
@property
def human_start(self):
start = "Unknown"
if self.processing_started:
start = self.processing_started.strftime('%B %d, %Y at %H:%M')
return start
@property
def import_type(self) -> str:
class_name = self.__class__.__name__
if class_name == 'AudioscrobblerTSVImport':
return "Audioscrobbler"
if class_name == 'KoReaderImport':
return "KoReader"
if self.__class__.__name__ == 'LastFMImport':
return "LastFM"
return "Generic"
def process(self, force=False): def process(self, force=False):
logger.warning("Process not implemented") logger.warning("Process not implemented")
@ -98,6 +118,14 @@ class KoReaderImport(BaseFileImportMixin):
class Meta: class Meta:
verbose_name = "KOReader Import" verbose_name = "KOReader Import"
def __str__(self):
return f"KoReader import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:koreader-import-detail', kwargs={'slug': self.uuid}
)
def get_path(instance, filename): def get_path(instance, filename):
extension = filename.split('.')[-1] extension = filename.split('.')[-1]
uuid = instance.uuid uuid = instance.uuid
@ -126,6 +154,14 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
class Meta: class Meta:
verbose_name = "AudioScrobbler TSV Import" verbose_name = "AudioScrobbler TSV Import"
def __str__(self):
return f"Audioscrobbler import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:tsv-import-detail', kwargs={'slug': self.uuid}
)
def get_path(instance, filename): def get_path(instance, filename):
extension = filename.split('.')[-1] extension = filename.split('.')[-1]
uuid = instance.uuid uuid = instance.uuid
@ -158,6 +194,14 @@ class LastFmImport(BaseFileImportMixin):
class Meta: class Meta:
verbose_name = "Last.FM Import" verbose_name = "Last.FM Import"
def __str__(self):
return f"LastFM import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:lastfm-import-detail', kwargs={'slug': self.uuid}
)
def process(self, import_all=False): def process(self, import_all=False):
"""Import scrobbles found on LastFM""" """Import scrobbles found on LastFM"""
if self.processed_finished: if self.processed_finished:
@ -203,6 +247,7 @@ class ChartRecord(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL) user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
rank = models.IntegerField() rank = models.IntegerField()
count = models.IntegerField(default=0)
year = models.IntegerField(default=timezone.now().year) year = models.IntegerField(default=timezone.now().year)
month = models.IntegerField(**BNULL) month = models.IntegerField(**BNULL)
week = models.IntegerField(**BNULL) week = models.IntegerField(**BNULL)
@ -219,6 +264,8 @@ class ChartRecord(TimeStampedModel):
media_obj = self.video media_obj = self.video
if self.track: if self.track:
media_obj = self.track media_obj = self.track
if self.artist:
media_obj = self.artist
return media_obj return media_obj
@property @property
@ -267,6 +314,26 @@ class ChartRecord(TimeStampedModel):
def __str__(self): def __str__(self):
return f"#{self.rank} in {self.period} - {self.media_obj}" return f"#{self.rank} in {self.period} - {self.media_obj}"
@classmethod
def build(cls, user, **kwargs):
build_charts(user=user, **kwargs)
@classmethod
def for_year(cls, user, year):
return cls.objects.filter(year=year, user=user)
@classmethod
def for_month(cls, user, year, month):
return cls.objects.filter(year=year, month=month, user=user)
@classmethod
def for_day(cls, user, year, day, month):
return cls.objects.filter(year=year, month=month, day=day, user=user)
@classmethod
def for_week(cls, user, year, week):
return cls.objects.filter(year=year, week=week, user=user)
class Scrobble(TimeStampedModel): class Scrobble(TimeStampedModel):
"""A scrobble tracks played media items by a user.""" """A scrobble tracks played media items by a user."""

View File

@ -89,10 +89,6 @@ def mopidy_scrobble_track(
"mopidy_status": data_dict.get("status"), "mopidy_status": data_dict.get("status"),
} }
# 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(track, user_id, mopidy_data) scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
return scrobble return scrobble
@ -130,7 +126,7 @@ def jellyfin_scrobble_track(
) )
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them # Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
if not data_dict.get("PlaybackPositionTicks") or null_position_on_progress: if null_position_on_progress:
logger.error("No playback position tick from Jellyfin, aborting") logger.error("No playback position tick from Jellyfin, aborting")
return return
@ -152,7 +148,6 @@ def jellyfin_scrobble_track(
) )
track = get_or_create_track( track = get_or_create_track(
title=data_dict.get("Name"), title=data_dict.get("Name"),
mbid=data_dict.get(JELLYFIN_POST_KEYS["TRACK_MB_ID"]),
artist=artist, artist=artist,
album=album, album=album,
run_time_ticks=run_time_ticks, run_time_ticks=run_time_ticks,

View File

@ -6,7 +6,7 @@ from typing import Optional
import pytz import pytz
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.db.models import Count, Q from django.db.models import Count, Q, ExpressionWrapper, OuterRef, Subquery
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,12 +30,14 @@ def get_scrobble_count_qs(
user=None, user=None,
model_str="Track", model_str="Track",
) -> dict[str, int]: ) -> dict[str, int]:
tz = settings.TIME_ZONE tz = settings.TIME_ZONE
if user and user.is_authenticated: if user and user.is_authenticated:
tz = pytz.timezone(user.profile.timezone) tz = pytz.timezone(user.profile.timezone)
tz = pytz.utc
data_model = apps.get_model(app_label='music', model_name='Track') data_model = apps.get_model(app_label='music', model_name='Track')
if model_str == "Artist":
data_model = apps.get_model(app_label='music', model_name='Artist')
if model_str == "Video": if model_str == "Video":
data_model = apps.get_model(app_label='videos', model_name='Video') data_model = apps.get_model(app_label='videos', model_name='Video')
if model_str == "SportEvent": if model_str == "SportEvent":
@ -43,6 +45,12 @@ def get_scrobble_count_qs(
app_label='sports', model_name='SportEvent' app_label='sports', model_name='SportEvent'
) )
if model_str == "Artist":
base_qs = data_model.objects.filter(
track__scrobble__user=user,
track__scrobble__played_to_completion=True,
)
else:
base_qs = data_model.objects.filter( base_qs = data_model.objects.filter(
scrobble__user=user, scrobble__user=user,
scrobble__played_to_completion=True, scrobble__played_to_completion=True,
@ -56,29 +64,41 @@ def get_scrobble_count_qs(
start = datetime(year, 1, 1, tzinfo=tz) start = datetime(year, 1, 1, tzinfo=tz)
end = datetime(year, 12, 31, tzinfo=tz) end = datetime(year, 12, 31, tzinfo=tz)
if month:
if year and day and month:
logger.debug('Filtering by year, month and day')
start = datetime(year, month, day, 0, 0, tzinfo=tz)
end = datetime(year, month, day, 23, 59, tzinfo=tz)
elif year and week:
logger.debug('Filtering by year and week')
start, end = get_start_end_dates_by_week(year, week, tz)
elif month:
logger.debug('Filtering by month')
end_day = calendar.monthrange(year, month)[1] end_day = calendar.monthrange(year, month)[1]
start = datetime(year, month, 1, tzinfo=tz) start = datetime(year, month, 1, tzinfo=tz)
end = datetime(year, month, end_day, tzinfo=tz) end = datetime(year, month, end_day, tzinfo=tz)
elif week:
start, end = get_start_end_dates_by_week(year, week, tz)
elif day and month:
start = datetime(year, month, day, 0, 0, tzinfo=tz)
end = datetime(year, month, day, 23, 59, tzinfo=tz)
elif day and not month:
logger.warning('Day provided with month, defaulting ot all-time')
date_filter = Q( if model_str == "Artist":
scrobble__timestamp__gte=start, scrobble__timestamp__lte=end scrobble_date_filter = Q(
track__scrobble__timestamp__gte=start,
track__scrobble__timestamp__lte=end,
) )
qs = (
return ( base_qs.filter(scrobble_date_filter)
base_qs.annotate( .annotate(scrobble_count=Count("track__scrobble", distinct=True))
scrobble_count=Count("scrobble", filter=Q(date_filter))
)
.filter(date_filter)
.order_by("-scrobble_count") .order_by("-scrobble_count")
) )
else:
scrobble_date_filter = Q(
scrobble__timestamp__gte=start, scrobble__timestamp__lte=end
)
qs = (
base_qs.filter(scrobble_date_filter)
.annotate(scrobble_count=Count("scrobble", distinct=True))
.order_by("-scrobble_count")
)
return qs
def build_charts( def build_charts(
@ -109,7 +129,14 @@ def build_charts(
'user': user, 'user': user,
} }
chart_record['rank'] = ranks[result.scrobble_count] chart_record['rank'] = ranks[result.scrobble_count]
chart_record['count'] = result.scrobble_count
if model_str == 'Track': if model_str == 'Track':
chart_record['track'] = result chart_record['track'] = result
if model_str == 'Video':
chart_record['video'] = result
if model_str == 'Artist':
chart_record['artist'] = result
chart_records.append(ChartRecord(**chart_record)) chart_records.append(ChartRecord(**chart_record))
ChartRecord.objects.bulk_create(chart_records, ignore_conflicts=True) ChartRecord.objects.bulk_create(
chart_records, ignore_conflicts=True, batch_size=500
)

View File

@ -0,0 +1,33 @@
import json
import logging
import requests
from django.conf import settings
THEAUDIODB_API_KEY = getattr(settings, "THEAUDIODB_API_KEY")
SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/search.php?s="
logger = logging.getLogger(__name__)
def lookup_artist_from_tadb(name: str) -> dict:
artist_info = {}
response = requests.get(SEARCH_URL + name)
if response.status_code != 200:
logger.warn(f"Bad response from TADB: {response.status_code}")
return {}
if not response.content:
logger.warn(f"Bad content from TADB: {response.content}")
return {}
results = json.loads(response.content)
artist = results['artists'][0]
artist_info['biography'] = artist['strBiographyEN']
artist_info['genre'] = artist['strGenre']
artist_info['mood'] = artist['strMood']
artist_info['thumb_url'] = artist['strArtistThumb']
return artist_info

View File

@ -42,4 +42,29 @@ urlpatterns = [
name='mopidy-webhook', name='mopidy-webhook',
), ),
path('export/', views.export, name='export'), path('export/', views.export, name='export'),
path(
'imports/',
views.ScrobbleImportListView.as_view(),
name='import-detail',
),
path(
'imports/tsv/<slug:slug>/',
views.ScrobbleTSVImportDetailView.as_view(),
name='tsv-import-detail',
),
path(
'imports/lastfm/<slug:slug>/',
views.ScrobbleLastFMImportDetailView.as_view(),
name='lastfm-import-detail',
),
path(
'imports/koreader/<slug:slug>/',
views.ScrobbleKoReaderImportDetailView.as_view(),
name='koreader-import-detail',
),
path(
'charts/',
views.ChartRecordView.as_view(),
name='charts-home',
),
] ]

View File

@ -1,17 +1,20 @@
import calendar
import json import json
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from django.db.models.query import QuerySet
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.db.models.fields import timezone from django.db.models.fields import timezone
from django.http import FileResponse, HttpResponseRedirect, JsonResponse from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from music.aggregators import ( from music.aggregators import (
@ -39,6 +42,7 @@ from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.imdb import lookup_video_from_imdb from scrobbles.imdb import lookup_video_from_imdb
from scrobbles.models import ( from scrobbles.models import (
AudioScrobblerTSVImport, AudioScrobblerTSVImport,
ChartRecord,
KoReaderImport, KoReaderImport,
LastFmImport, LastFmImport,
Scrobble, Scrobble,
@ -67,17 +71,7 @@ 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)
user = self.request.user user = self.request.user
now = timezone.now()
if user.is_authenticated: if user.is_authenticated:
if user.profile:
timezone.activate(pytz.timezone(user.profile.timezone))
now = timezone.localtime(timezone.now())
data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True,
is_paused=False,
timestamp__lte=now,
user=user,
)
completed_for_user = Scrobble.objects.filter( completed_for_user = Scrobble.objects.filter(
played_to_completion=True, user=user played_to_completion=True, user=user
@ -93,14 +87,11 @@ class RecentScrobbleList(ListView):
data['sport_scrobble_list'] = completed_for_user.filter( data['sport_scrobble_list'] = completed_for_user.filter(
sport_event__isnull=False sport_event__isnull=False
).order_by('-timestamp')[:15] ).order_by('-timestamp')[:15]
data['active_imports'] = AudioScrobblerTSVImport.objects.filter(
# data['top_daily_tracks'] = top_tracks() processing_started__isnull=False,
data['top_weekly_tracks'] = top_tracks(user, filter='week') processed_finished__isnull=True,
data['top_monthly_tracks'] = top_tracks(user, filter='month') user=self.request.user,
)
# data['top_daily_artists'] = top_artists()
data['top_weekly_artists'] = top_artists(user, filter='week')
data['top_monthly_artists'] = top_artists(user, filter='month')
data["weekly_data"] = week_of_scrobbles(user=user) data["weekly_data"] = week_of_scrobbles(user=user)
@ -115,6 +106,57 @@ class RecentScrobbleList(ListView):
).order_by('-timestamp')[:15] ).order_by('-timestamp')[:15]
class ScrobbleImportListView(TemplateView):
template_name = "scrobbles/import_list.html"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data['object_list'] = []
context_data["tsv_imports"] = AudioScrobblerTSVImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
context_data["koreader_imports"] = KoReaderImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
context_data["lastfm_imports"] = LastFmImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
return context_data
class BaseScrobbleImportDetailView(DetailView):
slug_field = 'uuid'
template_name = "scrobbles/import_detail.html"
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
title = "Generic Scrobble Import"
if self.model == KoReaderImport:
title = "KoReader Import"
if self.model == AudioScrobblerTSVImport:
title = "Audioscrobbler TSV Import"
if self.model == LastFmImport:
title = "LastFM Import"
context_data['title'] = title
return context_data
class ScrobbleKoReaderImportDetailView(BaseScrobbleImportDetailView):
model = KoReaderImport
class ScrobbleTSVImportDetailView(BaseScrobbleImportDetailView):
model = AudioScrobblerTSVImport
class ScrobbleLastFMImportDetailView(BaseScrobbleImportDetailView):
model = LastFmImport
class ManualScrobbleView(FormView): class ManualScrobbleView(FormView):
form_class = ScrobbleForm form_class = ScrobbleForm
template_name = 'scrobbles/manual_form.html' template_name = 'scrobbles/manual_form.html'
@ -358,3 +400,149 @@ def export(request):
response["Content-Disposition"] = f'attachment; filename="{filename}"' response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response return response
class ChartRecordView(TemplateView):
template_name = 'scrobbles/chart_index.html'
@staticmethod
def get_media_filter(media_type: str = "Track"):
media_filter = Q()
if media_type == 'Track':
media_filter = Q(track__isnull=False)
if media_type == 'Artist':
media_filter = Q(artist__isnull=False)
if media_type == 'Series':
media_filter = Q(series__isnull=False)
if media_type == 'Video':
media_filter = Q(video__isnull=False)
return media_filter
def get_chart_records(self, media_type: str = "Track", **kwargs):
media_filter = self.get_media_filter(media_type)
charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **kwargs
).order_by("rank")
if charts.count() == 0:
ChartRecord.build(
user=self.request.user, model_str=media_type, **kwargs
)
charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **kwargs
).order_by("rank")
return charts
def get_chart(
self, period: str = "all_time", limit=15, media: str = "Track"
) -> QuerySet:
chart = QuerySet()
now = timezone.now()
if period == "all_time":
chart = self.get_chart_records(media_type=media)
if period == "today":
chart = self.get_chart_records(
media_type=media,
day=now.day,
month=now.month,
year=now.year,
)
if period == "week":
chart = self.get_chart_records(
media_type=media,
year=now.year,
week=now.isocalendar()[1],
)
if period == "month":
chart = self.get_chart_records(
media_type=media,
year=now.year,
month=now.month,
)
if period == "year":
chart = self.get_chart_records(
media_type=media,
year=now.year,
)
return chart[:limit]
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
date = self.request.GET.get('date')
media_type = self.request.GET.get('media')
user = self.request.user
params = {}
context_data['artist_charts'] = {}
if not date:
context_data['artist_charts'] = {
"today": top_artists(user, filter="today")[:30],
"week": top_artists(user, filter="week")[:30],
"month": top_artists(user, filter="month")[:30],
"all": top_artists(user),
}
context_data['track_charts'] = {
"today": top_tracks(user, filter="today")[:30],
"week": top_tracks(user, filter="week")[:30],
"month": top_tracks(user, filter="month")[:30],
"all": top_tracks(user),
}
return context_data
now = timezone.now()
year = now.year
params = {'year': year}
name = f"Chart for {year}"
date_params = date.split('-')
year = int(date_params[0])
in_progress = False
if len(date_params) == 2:
if 'W' in date_params[1]:
week = int(date_params[1].strip('W"'))
params['week'] = week
start = datetime.strptime(date + "-1", "%Y-W%W-%w").replace(
tzinfo=pytz.utc
)
end = start + timedelta(days=6)
in_progress = start <= now <= end
as_str = start.strftime('Week of %B %d, %Y')
name = f"Chart for {as_str}"
else:
month = int(date_params[1])
params['month'] = month
month_str = calendar.month_name[month]
name = f"Chart for {month_str} {year}"
in_progress = now.month == month and now.year == year
if len(date_params) == 3:
month = int(date_params[1])
day = int(date_params[2])
params['month'] = month
params['day'] = day
month_str = calendar.month_name[month]
name = f"Chart for {month_str} {day}, {year}"
in_progress = (
now.month == month and now.year == year and now.day == day
)
media_filter = self.get_media_filter(media_type)
charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params
).order_by("rank")
if charts.count() == 0:
ChartRecord.build(
user=self.request.user, model_str=media_type, **params
)
charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params
).order_by("rank")
if in_progress:
# TODO recalculate
...
context_data['name'] = name
context_data['in_progress'] = in_progress
return context_data

View File

@ -6,6 +6,7 @@ from videos.models import Series, Video
class MovieListView(generic.ListView): class MovieListView(generic.ListView):
model = Video model = Video
template_name = "videos/movie_list.html"
def get_queryset(self): def get_queryset(self):
return Video.objects.filter(video_type=Video.VideoType.MOVIE) return Video.objects.filter(video_type=Video.VideoType.MOVIE)

View File

@ -2,6 +2,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
import dj_database_url import dj_database_url
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv from dotenv import load_dotenv
@ -50,13 +51,9 @@ DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
# Used to dump data coming from srobbling sources, helpful for building new inputs # Used to dump data coming from srobbling sources, helpful for building new inputs
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False) DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2") THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
THESPORTSDB_BASE_URL = os.getenv( THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
"VROBBLER_THESPORTSDB_BASE_URL", "https://www.thesportsdb.com/api/v1/json/"
)
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "") TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY") LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY") LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
@ -71,6 +68,10 @@ CSRF_TRUSTED_ORIGINS = [
X_FRAME_OPTIONS = "SAMEORIGIN" X_FRAME_OPTIONS = "SAMEORIGIN"
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None) REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
if REDIS_URL:
print(f"Sending tasks to redis@{REDIS_URL.split('@')[-1]}")
else:
print("Eagerly running all tasks")
CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False) CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False)
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/" CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
@ -135,6 +136,7 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"videos.context_processors.video_lists", "videos.context_processors.video_lists",
"music.context_processors.music_lists", "music.context_processors.music_lists",
"scrobbles.context_processors.now_playing",
], ],
}, },
}, },
@ -150,10 +152,18 @@ DATABASES = {
conn_max_age=600, conn_max_age=600,
), ),
} }
if TESTING: if TESTING:
DATABASES = { DATABASES = {
"default": dj_database_url.config(default="sqlite:///testdb.sqlite3") "default": dj_database_url.config(default="sqlite:///testdb.sqlite3")
} }
db_str = ""
if 'sqlite' in DATABASES['default']['ENGINE']:
db_str = f"Connected to sqlite@{DATABASES['default']['NAME']}"
if 'postgresql' in DATABASES['default']['ENGINE']:
db_str = f"Connected to postgres@{DATABASES['default']['HOST']}/{DATABASES['default']['NAME']}"
if db_str:
print(db_str)
CACHES = { CACHES = {

View File

@ -204,25 +204,6 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% if now_playing_list and user.is_authenticated %}
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
<div>
{{scrobble.media_obj.title}}<br/>
{% if scrobble.media_obj.subtitle %}<em>{{scrobble.media_obj.subtitle}}</em><br/>{% endif %}
<small>{{scrobble.timestamp|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>
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
<a href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
</div>
<hr/>
{% endfor %}
</ul>
{% endif %}
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" aria-current="page" href="/"> <a class="nav-link active" aria-current="page" href="/">
@ -230,6 +211,12 @@
Dashboard Dashboard
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="/charts/">
<span data-feather="bar-chart"></span>
Charts
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" aria-current="page" href="/tracks/"> <a class="nav-link" aria-current="page" href="/tracks/">
<span data-feather="music"></span> <span data-feather="music"></span>
@ -251,7 +238,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/series/"> <a class="nav-link" href="/series/">
<span data-feather="tv"></span> <span data-feather="tv"></span>
Series TV Shows
</a> </a>
</li> </li>
{% if user.is_authenticated %} {% if user.is_authenticated %}
@ -265,6 +252,40 @@
</ul> </ul>
{% block extra_nav %} {% block extra_nav %}
{% endblock %} {% endblock %}
<hr/>
{% if now_playing_list and user.is_authenticated %}
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
<div>
{% if scrobble.media_obj.album.cover_image %}
<td><img src="{{scrobble.track.album.cover_image.url}}" width=120 height=120
style="border:1px solid black; " /></td><br/>
{% endif %}
{{scrobble.media_obj.title}}<br/>
{% if scrobble.media_obj.subtitle %}<em>{{scrobble.media_obj.subtitle}}</em><br/>{% endif %}
<small>{{scrobble.timestamp|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>
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
<a href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
<hr />
</div>
{% endfor %}
</ul>
<hr/>
{% endif %}
{% if active_imports %}
{% for import in active_imports %}
<ul style="padding-right:10px;">
<li>Import in progress ({{import.processing_started|naturaltime}})</li>
</ul>
{% endfor %}
{% endif %}
</div> </div>
</nav> </nav>

View File

@ -1,8 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4"> <main class="col-md-9 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"> <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">{% block title %}{% endblock %} </h1> <h1 class="h2">{% block title %}{% endblock %} </h1>
<div class="btn-toolbar mb-2 mb-md-0"> <div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2"> <div class="btn-group me-2">

View File

@ -0,0 +1,94 @@
{% extends "base_list.html" %}
{% block title %}{{name}}{% endblock %}
{% block lists %}
<div class="row">
<h2>Top Artists</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for chart_name in artist_charts.keys %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}" id="artist-{{chart_name}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{chart_name}}"
type="button" role="tab" aria-controls="home" aria-selected="true">
{{chart_name}}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="artistTabContent">
{% for chart_name, artists in artist_charts.items %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}" id="artist-{{chart_name}}" role="tabpanel"
aria-labelledby="artist-{[chart}}-tab">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for artist in artists %}
<tr>
<td>{{artist.rank}}</td>
<td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
<td>{{artist.num_scrobbles}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="row">
<h2>Top Tracks</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for chart_name in track_charts.keys %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}" id="track-{{chart_name}}-tab" data-bs-toggle="tab" data-bs-target="#track-{{chart_name}}"
type="button" role="tab" aria-controls="home" aria-selected="true">
{{chart_name}}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="trackTabContent">
{% for chart_name, tracks in track_charts.items %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}" id="track-{{chart_name}}" role="tabpanel"
aria-labelledby="track-{[chart_name}}-tab">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Artist</th>
<th scope="col">Track</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td>{{track.rank}}</td>
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td>
<td>{{track.num_scrobbles}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base_detail.html" %}
{% block title %}{{title}}{% endblock %}
{% block details %}
<div class="row">
<div class="col-md">
<p>Import started: {{object.processing_started}}</p>
<p>Import finished: {{object.processed_finished}}</p>
<p>Imported {{object.process_count}} scrobbles</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends "base_list.html" %}
{% block title %}Scrobble Imports{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Date</th>
<th scope="col">Scrobbles Imported</th>
<th scope="col">Finished</th>
</tr>
</thead>
<tbody>
{% for obj in tsv_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
{% for obj in lastfm_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
{% for obj in koreader_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -46,144 +46,18 @@
This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p> This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md">
<ul class="nav nav-tabs" id="myTab" role="tablist"> <ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened"
data-bs-target="#artists-week" type="button" role="tab" aria-controls="home" type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button>
aria-selected="true">Weekly Artists</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="artist-month-tab" data-bs-toggle="tab"
data-bs-target="#artists-month" type="button" role="tab" aria-controls="home"
aria-selected="true">Monthly 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>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-month"
type="button" role="tab" aria-controls="profile" aria-selected="false">Monthly
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 class="tab-pane fade show" id="tracks-month" role="tabpanel"
aria-labelledby="tracks-month-tab">
<h2>Top tracks this month</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_monthly_tracks %}
<tr>
<td>{{track.num_scrobbles}}</td>
<td>{{track.title}}</td>
<td>{{track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show " id="artists-month" role="tabpanel"
aria-labelledby="artists-month-tab">
<h2>Top artists this month</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_monthly_artists %}
<tr>
<td>{{artist.num_scrobbles}}</td>
<td>{{artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<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">Tracks</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched" <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">Videos</button> type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted"
data-bs-target="#latest-podcasted" type="button" role="tab" aria-controls="profile" type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button>
aria-selected="false">Podcasts</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports" <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
@ -210,7 +84,7 @@
<tr> <tr>
<td>{{scrobble.timestamp|naturaltime}}</td> <td>{{scrobble.timestamp|naturaltime}}</td>
{% if scrobble.track.album.cover_image %} {% if scrobble.track.album.cover_image %}
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50 <td><img src="{{scrobble.track.album.cover_image.url}}" width=25 height=25
style="border:1px solid black;" /></td> style="border:1px solid black;" /></td>
{% else %} {% else %}
<td>{{scrobble.track.album.name}}</td> <td>{{scrobble.track.album.name}}</td>
@ -240,7 +114,7 @@
{% for scrobble in video_scrobble_list %} {% for scrobble in video_scrobble_list %}
<tr> <tr>
<td>{{scrobble.timestamp|naturaltime}}</td> <td>{{scrobble.timestamp|naturaltime}}</td>
<td>{% if scrobble.video.tv_series %}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{% endif %} {{scrobble.video.title}}</td> <td>{% if scrobble.video.tv_series%}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{%endif %} {{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %} <td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}
</td> </td>
</tr> </tr>
@ -250,8 +124,7 @@
</div> </div>
</div> </div>
<div class="tab-pane fade show" id="latest-sports" role="tabpanel" <div class="tab-pane fade show" id="latest-sports" role="tabpanel" aria-labelledby="latest-sports-tab">
aria-labelledby="latest-sports-tab">
<h2>Latest Sports</h2> <h2>Latest Sports</h2>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
@ -299,8 +172,6 @@
</table> </table>
</div> </div>
</div> </div>
</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,28 +1,28 @@
{% extends "base.html" %} {% extends "base_list.html" %}
{% block content %} {% block lists %}
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4"> <div class="row">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> <div class="col-md">
<h1 class="h2">Movies</h1> <div class="table-responsive">
<div class="btn-toolbar mb-2 mb-md-0"> <table class="table table-striped table-sm">
<div class="btn-group me-2"> <thead>
<button type="button" class="btn btn-sm btn-outline-secondary">Share</button> <tr>
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button> <th scope="col">Title</th>
</div> <th scope="col">Scrobbles</th>
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle"> <th scope="col">All time</th>
<span data-feather="calendar"></span> </tr>
This week </thead>
</button> <tbody>
</div> {% for obj in object_list %}
</div> <tr>
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
<div class="container"> <td>{{obj.scrobble_set.count}}</td>
<td></td>
<ul> </tr>
{% for movie in object_list %}
<li>{{movie}}</li>
{% endfor %} {% endfor %}
</ul> </tbody>
</table>
</div> </div>
</main> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,22 +1,32 @@
{% extends "base.html" %} {% extends "base_list.html" %}
{% block content %} {% block lists %}
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4"> <div class="row">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> <div class="col-md">
<h1 class="h2">Series</h1> <div class="table-responsive">
<div class="btn-toolbar mb-2 mb-md-0"> <table class="table table-striped table-sm">
<div class="btn-group me-2"> <thead>
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button> <tr>
</div> <th scope="col">Series</th>
</div> <th scope="col">Episode</th>
</div> <th scope="col">Scrobbles</th>
<th scope="col">All time</th>
<div class="container"> </tr>
<ul> </thead>
{% for movie in object_list %} <tbody>
<li>{{movie}}</li> {% for obj in object_list %}
{% for video in obj.video_set.all %}
<tr>
<td><a href="{{video.get_absolute_url}}">{{video}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
<td>{{video.scrobble_set.count}}</td>
<td></td>
</tr>
{% endfor %} {% endfor %}
</ul> {% endfor %}
</tbody>
</table>
</div> </div>
</main> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,14 +1,37 @@
{% extends "base.html" %} {% extends "base_detail.html" %}
{% block title %}Videos{% endblock %} {% block title %}{{object.name}}{% endblock %}
{% block content %} {% block details %}
{{object}}
<div class="row">
<h2>{{object.tv_series}}</h2>
<div class="col-md">
<h3>Last scrobbles</h3>
<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">Series</th>
<th scope="col">Season</th>
<th scope="col">Episode</th>
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all %} {% for scrobble in object.scrobble_set.all %}
<ul> <tr>
<li>{{scrobble}}</li> <td>{{scrobble.timestamp}}</td>
</ul> <td>{{scrobble.video.title}}</td>
<td>{{scrobble.video.tv_series}}</td>
<td>{{scrobble.video.season_number}}</td>
<td>{{scrobble.video.episode_number}}</td>
</tr>
{% endfor %} {% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

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