Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56e5728245 | |||
| 6ff170e169 | |||
| 86d1cf0d65 | |||
| a0101bf1ae | |||
| 457afdc9ef | |||
| d5bf6440b0 | |||
| 803ed7d8d7 | |||
| 93c4dd3d3b |
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.10.2"
|
||||
version = "0.11.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -55,7 +55,9 @@ def scrobble_counts(user=None):
|
||||
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()
|
||||
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)
|
||||
user_filter = Q(user=user)
|
||||
|
||||
start_of_today = datetime.combine(
|
||||
now.date(), datetime.min.time(), now.tzinfo
|
||||
)
|
||||
if not start:
|
||||
start = datetime.combine(now.date(), datetime.min.time(), now.tzinfo)
|
||||
|
||||
scrobble_day_dict = {}
|
||||
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)
|
||||
|
||||
for day in range(6, -1, -1):
|
||||
start = start_of_today - timedelta(days=day)
|
||||
end = datetime.combine(start, datetime.max.time(), now.tzinfo)
|
||||
day_of_week = start.strftime('%A')
|
||||
start_day = start - timedelta(days=day)
|
||||
end = datetime.combine(start_day, datetime.max.time(), now.tzinfo)
|
||||
day_of_week = start_day.strftime('%A')
|
||||
|
||||
scrobble_day_dict[day_of_week] = base_qs.filter(
|
||||
media_filter,
|
||||
timestamp__gte=start,
|
||||
timestamp__gte=start_day,
|
||||
timestamp__lte=end,
|
||||
played_to_completion=True,
|
||||
).count()
|
||||
@ -92,7 +93,7 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
|
||||
|
||||
|
||||
def top_tracks(
|
||||
user: "User", filter: str = "today", limit: int = 15
|
||||
user: "User", filter: str = "today", limit: int = 30
|
||||
) -> List["Track"]:
|
||||
|
||||
now = timezone.now()
|
||||
@ -108,7 +109,9 @@ def top_tracks(
|
||||
starting_day_of_current_month = now.date().replace(day=1)
|
||||
starting_day_of_current_year = now.date().replace(month=1, day=1)
|
||||
|
||||
time_filter = Q(scrobble__timestamp__gte=start_of_today)
|
||||
time_filter = Q()
|
||||
if filter == "today":
|
||||
time_filter = Q(scrobble__timestamp__gte=start_of_today)
|
||||
if filter == "week":
|
||||
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_week)
|
||||
if filter == "month":
|
||||
@ -118,7 +121,13 @@ def top_tracks(
|
||||
|
||||
return (
|
||||
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]
|
||||
)
|
||||
|
||||
@ -142,8 +151,14 @@ def top_artists(
|
||||
|
||||
return (
|
||||
Artist.objects.filter(time_filter)
|
||||
.annotate(num_scrobbles=Count("track__scrobble", distinct=True))
|
||||
.order_by("-num_scrobbles")[:limit]
|
||||
.annotate(
|
||||
num_scrobbles=Count(
|
||||
"track__scrobble",
|
||||
filter=Q(track__scrobble__played_to_completion=True),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.order_by("-num_scrobbles")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
20
vrobbler/apps/music/migrations/0011_artist_thumbnail.py
Normal file
20
vrobbler/apps/music/migrations/0011_artist_thumbnail.py
Normal 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/'
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,15 +1,18 @@
|
||||
import logging
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Dict, Optional
|
||||
from urllib.request import urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
import musicbrainzngs
|
||||
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.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
from scrobbles.theaudiodb import lookup_artist_from_tadb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
@ -18,7 +21,11 @@ BNULL = {"blank": True, "null": True}
|
||||
class Artist(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
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)
|
||||
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
|
||||
|
||||
class Meta:
|
||||
unique_together = [['name', 'musicbrainz_id']]
|
||||
@ -53,6 +60,22 @@ class Artist(TimeStampedModel):
|
||||
|
||||
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):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from musicbrainzngs.caa import musicbrainz
|
||||
|
||||
from scrobbles.musicbrainz import (
|
||||
lookup_album_dict_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()
|
||||
if 'featuring' in name.lower():
|
||||
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)
|
||||
mbid = mbid or artist_dict['id']
|
||||
|
||||
logger.debug(f'Looking up artist {name} and mbid: {mbid}')
|
||||
artist, created = Artist.objects.get_or_create(
|
||||
name=name, musicbrainz_id=mbid
|
||||
)
|
||||
|
||||
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"])
|
||||
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||
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()
|
||||
|
||||
return artist
|
||||
|
||||
@ -78,27 +80,22 @@ def get_or_create_album(name: str, artist: Artist, mbid: str = None) -> Album:
|
||||
|
||||
def get_or_create_track(
|
||||
title: str,
|
||||
mbid: str,
|
||||
artist: Artist,
|
||||
album: Album,
|
||||
mbid: str = None,
|
||||
run_time=None,
|
||||
run_time_ticks=None,
|
||||
) -> Track:
|
||||
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:
|
||||
mbid = lookup_track_from_mb(
|
||||
title, artist.musicbrainz_id, album.musicbrainz_id
|
||||
title,
|
||||
artist.musicbrainz_id,
|
||||
album.musicbrainz_id,
|
||||
)['id']
|
||||
|
||||
track = Track.objects.filter(musicbrainz_id=mbid).first()
|
||||
|
||||
if not track:
|
||||
track = Track.objects.create(
|
||||
title=title,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from django.db.models import Count
|
||||
from django.views import generic
|
||||
from music.models import Track, Artist, Album
|
||||
from scrobbles.stats import get_scrobble_count_qs
|
||||
|
||||
@ -47,6 +47,7 @@ class ChartRecordAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"user",
|
||||
"rank",
|
||||
"count",
|
||||
"year",
|
||||
"week",
|
||||
"month",
|
||||
|
||||
17
vrobbler/apps/scrobbles/context_processors.py
Normal file
17
vrobbler/apps/scrobbles/context_processors.py
Normal 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,
|
||||
)
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -2,19 +2,21 @@ import calendar
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from books.models import Book
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from music.models import Artist, Track
|
||||
from books.models import Book
|
||||
from podcasts.models import Episode
|
||||
from profiles.utils import now_user_timezone
|
||||
from scrobbles.lastfm import LastFM
|
||||
from scrobbles.utils import check_scrobble_for_finish
|
||||
from sports.models import SportEvent
|
||||
from videos.models import Series, Video
|
||||
|
||||
from vrobbler.apps.scrobbles.stats import build_charts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
@ -34,6 +36,24 @@ class BaseFileImportMixin(TimeStampedModel):
|
||||
def __str__(self):
|
||||
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):
|
||||
logger.warning("Process not implemented")
|
||||
|
||||
@ -98,6 +118,14 @@ class KoReaderImport(BaseFileImportMixin):
|
||||
class Meta:
|
||||
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):
|
||||
extension = filename.split('.')[-1]
|
||||
uuid = instance.uuid
|
||||
@ -126,6 +154,14 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
||||
class Meta:
|
||||
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):
|
||||
extension = filename.split('.')[-1]
|
||||
uuid = instance.uuid
|
||||
@ -158,6 +194,14 @@ class LastFmImport(BaseFileImportMixin):
|
||||
class Meta:
|
||||
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):
|
||||
"""Import scrobbles found on LastFM"""
|
||||
if self.processed_finished:
|
||||
@ -203,6 +247,7 @@ class ChartRecord(TimeStampedModel):
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||
rank = models.IntegerField()
|
||||
count = models.IntegerField(default=0)
|
||||
year = models.IntegerField(default=timezone.now().year)
|
||||
month = models.IntegerField(**BNULL)
|
||||
week = models.IntegerField(**BNULL)
|
||||
@ -219,6 +264,8 @@ class ChartRecord(TimeStampedModel):
|
||||
media_obj = self.video
|
||||
if self.track:
|
||||
media_obj = self.track
|
||||
if self.artist:
|
||||
media_obj = self.artist
|
||||
return media_obj
|
||||
|
||||
@property
|
||||
@ -267,6 +314,26 @@ class ChartRecord(TimeStampedModel):
|
||||
def __str__(self):
|
||||
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):
|
||||
"""A scrobble tracks played media items by a user."""
|
||||
|
||||
@ -89,10 +89,6 @@ def mopidy_scrobble_track(
|
||||
"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)
|
||||
|
||||
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
|
||||
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")
|
||||
return
|
||||
|
||||
@ -152,7 +148,6 @@ def jellyfin_scrobble_track(
|
||||
)
|
||||
track = get_or_create_track(
|
||||
title=data_dict.get("Name"),
|
||||
mbid=data_dict.get(JELLYFIN_POST_KEYS["TRACK_MB_ID"]),
|
||||
artist=artist,
|
||||
album=album,
|
||||
run_time_ticks=run_time_ticks,
|
||||
|
||||
@ -6,7 +6,7 @@ from typing import Optional
|
||||
import pytz
|
||||
from django.apps import apps
|
||||
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__)
|
||||
@ -30,12 +30,14 @@ def get_scrobble_count_qs(
|
||||
user=None,
|
||||
model_str="Track",
|
||||
) -> dict[str, int]:
|
||||
|
||||
tz = settings.TIME_ZONE
|
||||
if user and user.is_authenticated:
|
||||
tz = pytz.timezone(user.profile.timezone)
|
||||
|
||||
tz = pytz.utc
|
||||
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":
|
||||
data_model = apps.get_model(app_label='videos', model_name='Video')
|
||||
if model_str == "SportEvent":
|
||||
@ -43,10 +45,16 @@ def get_scrobble_count_qs(
|
||||
app_label='sports', model_name='SportEvent'
|
||||
)
|
||||
|
||||
base_qs = data_model.objects.filter(
|
||||
scrobble__user=user,
|
||||
scrobble__played_to_completion=True,
|
||||
)
|
||||
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(
|
||||
scrobble__user=user,
|
||||
scrobble__played_to_completion=True,
|
||||
)
|
||||
|
||||
# Returna all media items with scrobble count annotated
|
||||
if not year:
|
||||
@ -56,29 +64,41 @@ def get_scrobble_count_qs(
|
||||
|
||||
start = datetime(year, 1, 1, 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]
|
||||
start = datetime(year, month, 1, 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(
|
||||
scrobble__timestamp__gte=start, scrobble__timestamp__lte=end
|
||||
)
|
||||
|
||||
return (
|
||||
base_qs.annotate(
|
||||
scrobble_count=Count("scrobble", filter=Q(date_filter))
|
||||
if model_str == "Artist":
|
||||
scrobble_date_filter = Q(
|
||||
track__scrobble__timestamp__gte=start,
|
||||
track__scrobble__timestamp__lte=end,
|
||||
)
|
||||
.filter(date_filter)
|
||||
.order_by("-scrobble_count")
|
||||
)
|
||||
qs = (
|
||||
base_qs.filter(scrobble_date_filter)
|
||||
.annotate(scrobble_count=Count("track__scrobble", distinct=True))
|
||||
.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(
|
||||
@ -109,7 +129,14 @@ def build_charts(
|
||||
'user': user,
|
||||
}
|
||||
chart_record['rank'] = ranks[result.scrobble_count]
|
||||
chart_record['count'] = result.scrobble_count
|
||||
if model_str == 'Track':
|
||||
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))
|
||||
ChartRecord.objects.bulk_create(chart_records, ignore_conflicts=True)
|
||||
ChartRecord.objects.bulk_create(
|
||||
chart_records, ignore_conflicts=True, batch_size=500
|
||||
)
|
||||
|
||||
33
vrobbler/apps/scrobbles/theaudiodb.py
Normal file
33
vrobbler/apps/scrobbles/theaudiodb.py
Normal 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
|
||||
@ -42,4 +42,29 @@ urlpatterns = [
|
||||
name='mopidy-webhook',
|
||||
),
|
||||
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',
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
import calendar
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Q
|
||||
from django.db.models.fields import timezone
|
||||
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
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.list import ListView
|
||||
from music.aggregators import (
|
||||
@ -39,6 +42,7 @@ from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
||||
from scrobbles.imdb import lookup_video_from_imdb
|
||||
from scrobbles.models import (
|
||||
AudioScrobblerTSVImport,
|
||||
ChartRecord,
|
||||
KoReaderImport,
|
||||
LastFmImport,
|
||||
Scrobble,
|
||||
@ -67,17 +71,7 @@ class RecentScrobbleList(ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
data = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
now = timezone.now()
|
||||
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(
|
||||
played_to_completion=True, user=user
|
||||
@ -93,14 +87,11 @@ class RecentScrobbleList(ListView):
|
||||
data['sport_scrobble_list'] = completed_for_user.filter(
|
||||
sport_event__isnull=False
|
||||
).order_by('-timestamp')[:15]
|
||||
|
||||
# data['top_daily_tracks'] = top_tracks()
|
||||
data['top_weekly_tracks'] = top_tracks(user, filter='week')
|
||||
data['top_monthly_tracks'] = top_tracks(user, filter='month')
|
||||
|
||||
# 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['active_imports'] = AudioScrobblerTSVImport.objects.filter(
|
||||
processing_started__isnull=False,
|
||||
processed_finished__isnull=True,
|
||||
user=self.request.user,
|
||||
)
|
||||
|
||||
data["weekly_data"] = week_of_scrobbles(user=user)
|
||||
|
||||
@ -115,6 +106,57 @@ class RecentScrobbleList(ListView):
|
||||
).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):
|
||||
form_class = ScrobbleForm
|
||||
template_name = 'scrobbles/manual_form.html'
|
||||
@ -358,3 +400,149 @@ def export(request):
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
|
||||
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
|
||||
|
||||
@ -6,6 +6,7 @@ from videos.models import Series, Video
|
||||
|
||||
class MovieListView(generic.ListView):
|
||||
model = Video
|
||||
template_name = "videos/movie_list.html"
|
||||
|
||||
def get_queryset(self):
|
||||
return Video.objects.filter(video_type=Video.VideoType.MOVIE)
|
||||
|
||||
@ -2,6 +2,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
import dj_database_url
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
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
|
||||
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
|
||||
|
||||
|
||||
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
|
||||
THESPORTSDB_BASE_URL = os.getenv(
|
||||
"VROBBLER_THESPORTSDB_BASE_URL", "https://www.thesportsdb.com/api/v1/json/"
|
||||
)
|
||||
THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
|
||||
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
||||
|
||||
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
|
||||
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
|
||||
|
||||
@ -71,6 +68,10 @@ CSRF_TRUSTED_ORIGINS = [
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
|
||||
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_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
|
||||
@ -135,6 +136,7 @@ TEMPLATES = [
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"videos.context_processors.video_lists",
|
||||
"music.context_processors.music_lists",
|
||||
"scrobbles.context_processors.now_playing",
|
||||
],
|
||||
},
|
||||
},
|
||||
@ -150,10 +152,18 @@ DATABASES = {
|
||||
conn_max_age=600,
|
||||
),
|
||||
}
|
||||
|
||||
if TESTING:
|
||||
DATABASES = {
|
||||
"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 = {
|
||||
|
||||
@ -204,25 +204,6 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% 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">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="/">
|
||||
@ -230,6 +211,12 @@
|
||||
Dashboard
|
||||
</a>
|
||||
</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">
|
||||
<a class="nav-link" aria-current="page" href="/tracks/">
|
||||
<span data-feather="music"></span>
|
||||
@ -251,7 +238,7 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/series/">
|
||||
<span data-feather="tv"></span>
|
||||
Series
|
||||
TV Shows
|
||||
</a>
|
||||
</li>
|
||||
{% if user.is_authenticated %}
|
||||
@ -265,6 +252,40 @@
|
||||
</ul>
|
||||
{% block extra_nav %}
|
||||
{% 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>
|
||||
</nav>
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
{% 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">
|
||||
<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">
|
||||
<h1 class="h2">{% block title %}{% endblock %} </h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
@ -15,4 +16,4 @@
|
||||
{% block lists %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
94
vrobbler/templates/scrobbles/chart_index.html
Normal file
94
vrobbler/templates/scrobbles/chart_index.html
Normal 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 %}
|
||||
13
vrobbler/templates/scrobbles/import_detail.html
Normal file
13
vrobbler/templates/scrobbles/import_detail.html
Normal 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 %}
|
||||
47
vrobbler/templates/scrobbles/import_list.html
Normal file
47
vrobbler/templates/scrobbles/import_list.html
Normal 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 %}
|
||||
@ -46,260 +46,131 @@
|
||||
This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="home-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#artists-week" type="button" role="tab" aria-controls="home"
|
||||
aria-selected="true">Weekly Artists</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="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>
|
||||
<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 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">Videos</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">Podcasts</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
|
||||
type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</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 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">Album</th>
|
||||
<th scope="col">Track</th>
|
||||
<th scope="col">Artist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in object_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
{% if scrobble.track.album.cover_image %}
|
||||
<td><img src="{{scrobble.track.album.cover_image.url}}" width=25 height=25
|
||||
style="border:1px solid black;" /></td>
|
||||
{% else %}
|
||||
<td>{{scrobble.track.album.name}}</td>
|
||||
{% endif %}
|
||||
<td>{{scrobble.track.title}}</td>
|
||||
<td>{{scrobble.track.artist.name}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 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">Videos</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">Podcasts</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
|
||||
type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</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">Album</th>
|
||||
<th scope="col">Track</th>
|
||||
<th scope="col">Artist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in object_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
{% if scrobble.track.album.cover_image %}
|
||||
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50
|
||||
style="border:1px solid black;" /></td>
|
||||
{% else %}
|
||||
<td>{{scrobble.track.album.name}}</td>
|
||||
{% endif %}
|
||||
<td>{{scrobble.track.title}}</td>
|
||||
<td>{{scrobble.track.artist.name}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="tab-pane fade show" id="latest-watched" role="tabpanel"
|
||||
aria-labelledby="latest-watched-tab">
|
||||
<h2>Latest watched</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Series</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in video_scrobble_list %}
|
||||
<tr>
|
||||
<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 %}{{scrobble.video.tv_series}}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade show" id="latest-watched" role="tabpanel"
|
||||
aria-labelledby="latest-watched-tab">
|
||||
<h2>Latest watched</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Series</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in video_scrobble_list %}
|
||||
<tr>
|
||||
<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 %}{{scrobble.video.tv_series}}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="tab-pane fade show" id="latest-sports" role="tabpanel" aria-labelledby="latest-sports-tab">
|
||||
<h2>Latest Sports</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">League</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in sport_scrobble_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
<td>{{scrobble.sport_event.title}}</td>
|
||||
<td>{{scrobble.sport_event.league.abbreviation}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade show" id="latest-sports" role="tabpanel"
|
||||
aria-labelledby="latest-sports-tab">
|
||||
<h2>Latest Sports</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">League</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in sport_scrobble_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
<td>{{scrobble.sport_event.title}}</td>
|
||||
<td>{{scrobble.sport_event.league.abbreviation}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 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>
|
||||
{% endif %}
|
||||
|
||||
@ -1,28 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base_list.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>
|
||||
{% 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">Title</th>
|
||||
<th scope="col">Scrobbles</th>
|
||||
<th scope="col">All time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for obj in object_list %}
|
||||
<tr>
|
||||
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
|
||||
<td>{{obj.scrobble_set.count}}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<ul>
|
||||
{% for movie in object_list %}
|
||||
<li>{{movie}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,22 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base_list.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>
|
||||
{% 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">Series</th>
|
||||
<th scope="col">Episode</th>
|
||||
<th scope="col">Scrobbles</th>
|
||||
<th scope="col">All time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<ul>
|
||||
{% for movie in object_list %}
|
||||
<li>{{movie}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,14 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base_detail.html" %}
|
||||
|
||||
{% block title %}Videos{% endblock %}
|
||||
{% block title %}{{object.name}}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{object}}
|
||||
{% block details %}
|
||||
|
||||
{% for scrobble in object.scrobble_set.all %}
|
||||
<ul>
|
||||
<li>{{scrobble}}</li>
|
||||
</ul>
|
||||
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
<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 %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp}}</td>
|
||||
<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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -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 %}
|
||||
Reference in New Issue
Block a user