Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5bf6440b0 | |||
| 803ed7d8d7 | |||
| 93c4dd3d3b | |||
| ab728de75f | |||
| 04b7214795 | |||
| 479fee6a5c | |||
| 40a126cf8b | |||
| 83c02aa00f | |||
| 0f44df2b9b | |||
| 16d1dcc125 |
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.10.1"
|
version = "0.11.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from scrobbles.models import Scrobble
|
|||||||
|
|
||||||
|
|
||||||
def build_scrobbles(client, request_data, num=7, spacing=2):
|
def build_scrobbles(client, request_data, num=7, spacing=2):
|
||||||
url = reverse('scrobbles:mopidy-websocket')
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
user = get_user_model().objects.create(username='Test User')
|
user = get_user_model().objects.create(username='Test User')
|
||||||
UserProfile.objects.create(user=user, timezone='US/Eastern')
|
UserProfile.objects.create(user=user, timezone='US/Eastern')
|
||||||
for i in range(num):
|
for i in range(num):
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from podcasts.models import Episode
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_get_not_allowed_from_mopidy(client, valid_auth_token):
|
def test_get_not_allowed_from_mopidy(client, valid_auth_token):
|
||||||
url = reverse('scrobbles:mopidy-websocket')
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
headers = {'Authorization': f'Token {valid_auth_token}'}
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
response = client.get(url, headers=headers)
|
response = client.get(url, headers=headers)
|
||||||
assert response.status_code == 405
|
assert response.status_code == 405
|
||||||
@ -18,7 +18,7 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_bad_mopidy_request_data(client, valid_auth_token):
|
def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||||
url = reverse('scrobbles:mopidy-websocket')
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
headers = {'Authorization': f'Token {valid_auth_token}'}
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
response = client.post(url, headers)
|
response = client.post(url, headers)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@ -32,7 +32,7 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
|||||||
def test_scrobble_mopidy_track(
|
def test_scrobble_mopidy_track(
|
||||||
client, mopidy_track_request_data, valid_auth_token
|
client, mopidy_track_request_data, valid_auth_token
|
||||||
):
|
):
|
||||||
url = reverse('scrobbles:mopidy-websocket')
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
headers = {'Authorization': f'Token {valid_auth_token}'}
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
url,
|
||||||
@ -55,7 +55,7 @@ def test_scrobble_mopidy_same_track_different_album(
|
|||||||
mopidy_track_diff_album_request_data,
|
mopidy_track_diff_album_request_data,
|
||||||
valid_auth_token,
|
valid_auth_token,
|
||||||
):
|
):
|
||||||
url = reverse('scrobbles:mopidy-websocket')
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
headers = {'Authorization': f'Token {valid_auth_token}'}
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
url,
|
||||||
@ -84,7 +84,7 @@ def test_scrobble_mopidy_same_track_different_album(
|
|||||||
def test_scrobble_mopidy_podcast(
|
def test_scrobble_mopidy_podcast(
|
||||||
client, mopidy_podcast_request_data, valid_auth_token
|
client, mopidy_podcast_request_data, valid_auth_token
|
||||||
):
|
):
|
||||||
url = reverse('scrobbles:mopidy-websocket')
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
headers = {'Authorization': f'Token {valid_auth_token}'}
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
url,
|
||||||
|
|||||||
@ -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,7 +78,7 @@ 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 = start - timedelta(days=day)
|
||||||
end = datetime.combine(start, datetime.max.time(), now.tzinfo)
|
end = datetime.combine(start, datetime.max.time(), now.tzinfo)
|
||||||
day_of_week = start.strftime('%A')
|
day_of_week = start.strftime('%A')
|
||||||
|
|
||||||
|
|||||||
@ -181,10 +181,18 @@ class Track(ScrobblableMixin):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('music:track_detail', kwargs={'slug': self.uuid})
|
return reverse('music:track_detail', kwargs={'slug': self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.artist
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mb_link(self):
|
def mb_link(self):
|
||||||
return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
|
return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info_link(self):
|
||||||
|
return self.mb_link
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
|
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -45,6 +45,14 @@ class Episode(ScrobblableMixin):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title}"
|
return f"{self.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.podcast
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info_link(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
|
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -14,6 +14,7 @@ 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()
|
||||||
@ -203,6 +204,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 +221,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 +271,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."""
|
||||||
|
|||||||
@ -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,10 +45,16 @@ def get_scrobble_count_qs(
|
|||||||
app_label='sports', model_name='SportEvent'
|
app_label='sports', model_name='SportEvent'
|
||||||
)
|
)
|
||||||
|
|
||||||
base_qs = data_model.objects.filter(
|
if model_str == "Artist":
|
||||||
scrobble__user=user,
|
base_qs = data_model.objects.filter(
|
||||||
scrobble__played_to_completion=True,
|
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
|
# Returna all media items with scrobble count annotated
|
||||||
if not year:
|
if not year:
|
||||||
@ -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,
|
||||||
return (
|
|
||||||
base_qs.annotate(
|
|
||||||
scrobble_count=Count("scrobble", filter=Q(date_filter))
|
|
||||||
)
|
)
|
||||||
.filter(date_filter)
|
qs = (
|
||||||
.order_by("-scrobble_count")
|
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(
|
def build_charts(
|
||||||
@ -109,7 +129,12 @@ 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)
|
||||||
|
|||||||
@ -24,6 +24,7 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
data_dict = {
|
data_dict = {
|
||||||
|
"EventId": event_id,
|
||||||
"ItemType": sport.default_event_type,
|
"ItemType": sport.default_event_type,
|
||||||
"Name": event.get('strEvent'),
|
"Name": event.get('strEvent'),
|
||||||
"AltName": event.get('strEventAlternate'),
|
"AltName": event.get('strEventAlternate'),
|
||||||
|
|||||||
@ -26,8 +26,25 @@ urlpatterns = [
|
|||||||
views.AudioScrobblerImportCreateView.as_view(),
|
views.AudioScrobblerImportCreateView.as_view(),
|
||||||
name='audioscrobbler-file-upload',
|
name='audioscrobbler-file-upload',
|
||||||
),
|
),
|
||||||
path('lastfm-import/', views.lastfm_import, name='lastfm-import'),
|
path(
|
||||||
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
|
'lastfm-import/',
|
||||||
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
|
views.lastfm_import,
|
||||||
|
name='lastfm-import',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'webhook/jellyfin/',
|
||||||
|
views.jellyfin_webhook,
|
||||||
|
name='jellyfin-webhook',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'webhook/mopidy/',
|
||||||
|
views.mopidy_webhook,
|
||||||
|
name='mopidy-webhook',
|
||||||
|
),
|
||||||
path('export/', views.export, name='export'),
|
path('export/', views.export, name='export'),
|
||||||
|
path(
|
||||||
|
'charts/',
|
||||||
|
views.ChartRecordView.as_view(),
|
||||||
|
name='charts-home',
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import calendar
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -6,12 +7,13 @@ 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 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 +41,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,
|
||||||
@ -216,7 +219,7 @@ def lastfm_import(request):
|
|||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def jellyfin_websocket(request):
|
def jellyfin_webhook(request):
|
||||||
data_dict = request.data
|
data_dict = request.data
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -248,7 +251,7 @@ def jellyfin_websocket(request):
|
|||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def mopidy_websocket(request):
|
def mopidy_webhook(request):
|
||||||
try:
|
try:
|
||||||
data_dict = json.loads(request.data)
|
data_dict = json.loads(request.data)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@ -358,3 +361,69 @@ 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'
|
||||||
|
|
||||||
|
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')
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if not media_type:
|
||||||
|
media_type = 'Track'
|
||||||
|
context_data['media_type'] = media_type
|
||||||
|
media_filter = Q(track__isnull=False)
|
||||||
|
if media_type == 'Video':
|
||||||
|
media_filter = Q(video__isnull=False)
|
||||||
|
if media_type == 'Artist':
|
||||||
|
media_filter = Q(artist__isnull=False)
|
||||||
|
|
||||||
|
year = timezone.now().year
|
||||||
|
params = {'year': year}
|
||||||
|
name = f"Chart for {year}"
|
||||||
|
|
||||||
|
if not date:
|
||||||
|
date = timezone.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
date_params = date.split('-')
|
||||||
|
year = int(date_params[0])
|
||||||
|
if len(date_params) == 2:
|
||||||
|
if 'W' in date_params[1]:
|
||||||
|
week = int(date_params[1].strip('W"'))
|
||||||
|
params['week'] = week
|
||||||
|
r = datetime.strptime(date + '-1', "%Y-W%W-%w").strftime(
|
||||||
|
'Week of %B %d, %Y'
|
||||||
|
)
|
||||||
|
name = f"Chart for {r}"
|
||||||
|
else:
|
||||||
|
month = int(date_params[1])
|
||||||
|
params['month'] = month
|
||||||
|
month_str = calendar.month_name[month]
|
||||||
|
name = f"Chart for {month_str} {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}"
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
context_data['object_list'] = charts
|
||||||
|
context_data['name'] = name
|
||||||
|
return context_data
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-23 15:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sports', '0007_sport_default_event_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sportevent',
|
||||||
|
name='thesportsdb_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -15,8 +15,9 @@ BNULL = {"blank": True, "null": True}
|
|||||||
|
|
||||||
|
|
||||||
class SportEventType(models.TextChoices):
|
class SportEventType(models.TextChoices):
|
||||||
UNKNOWN = 'UK', _('Unknown')
|
UNKNOWN = 'UK', _('Event')
|
||||||
GAME = 'GA', _('Game')
|
GAME = 'GA', _('Game')
|
||||||
|
RACE = 'RA', _('Race')
|
||||||
MATCH = 'MA', _('Match')
|
MATCH = 'MA', _('Match')
|
||||||
|
|
||||||
|
|
||||||
@ -89,6 +90,7 @@ class Round(TheSportsDbMixin):
|
|||||||
class SportEvent(ScrobblableMixin):
|
class SportEvent(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
|
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
|
||||||
|
|
||||||
|
thesportsdb_id = models.CharField(max_length=255, **BNULL)
|
||||||
event_type = models.CharField(
|
event_type = models.CharField(
|
||||||
max_length=2,
|
max_length=2,
|
||||||
choices=SportEventType.choices,
|
choices=SportEventType.choices,
|
||||||
@ -127,6 +129,18 @@ class SportEvent(ScrobblableMixin):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("sports:event_detail", kwargs={'slug': self.uuid})
|
return reverse("sports:event_detail", kwargs={'slug': self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.round.season.league
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sportsdb_link(self):
|
||||||
|
return f"https://thesportsdb.com/event/{self.thesportsdb_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info_link(self):
|
||||||
|
return self.sportsdb_link
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, data_dict: Dict) -> "Event":
|
def find_or_create(cls, data_dict: Dict) -> "Event":
|
||||||
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
||||||
@ -208,6 +222,7 @@ class SportEvent(ScrobblableMixin):
|
|||||||
away_team, _created = Team.objects.get_or_create(**away_team_dict)
|
away_team, _created = Team.objects.get_or_create(**away_team_dict)
|
||||||
|
|
||||||
event_dict = {
|
event_dict = {
|
||||||
|
"thesportsdb_id": data_dict.get("EventId"),
|
||||||
"title": data_dict.get("Name"),
|
"title": data_dict.get("Name"),
|
||||||
"event_type": sport.default_event_type,
|
"event_type": sport.default_event_type,
|
||||||
"home_team": home_team,
|
"home_team": home_team,
|
||||||
|
|||||||
@ -69,10 +69,24 @@ class Video(ScrobblableMixin):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("videos:video_detail", kwargs={'slug': self.uuid})
|
return reverse("videos:video_detail", kwargs={'slug': self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
if self.tv_series:
|
||||||
|
return self.tv_series
|
||||||
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def imdb_link(self):
|
def imdb_link(self):
|
||||||
return f"https://www.imdb.com/title/{self.imdb_id}"
|
return f"https://www.imdb.com/title/{self.imdb_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info_link(self):
|
||||||
|
return self.imdb_link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def link(self):
|
||||||
|
return self.imdb_link
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, data_dict: Dict) -> "Video":
|
def find_or_create(cls, data_dict: Dict) -> "Video":
|
||||||
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -173,12 +173,14 @@ AUTHENTICATION_BACKENDS = [
|
|||||||
"allauth.account.auth_backends.AuthenticationBackend",
|
"allauth.account.auth_backends.AuthenticationBackend",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# We have to ignore content negotiation because Jellyfin is a bad actor
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
|
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
'rest_framework.authentication.TokenAuthentication',
|
'rest_framework.authentication.TokenAuthentication',
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
],
|
],
|
||||||
|
'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'vrobbler.negotiation.IgnoreClientContentNegotiation',
|
||||||
"DEFAULT_FILTER_BACKENDS": [
|
"DEFAULT_FILTER_BACKENDS": [
|
||||||
"django_filters.rest_framework.DjangoFilterBackend"
|
"django_filters.rest_framework.DjangoFilterBackend"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -210,10 +210,7 @@
|
|||||||
{% for scrobble in now_playing_list %}
|
{% for scrobble in now_playing_list %}
|
||||||
<div>
|
<div>
|
||||||
{{scrobble.media_obj.title}}<br/>
|
{{scrobble.media_obj.title}}<br/>
|
||||||
{% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %}
|
{% if scrobble.media_obj.subtitle %}<em>{{scrobble.media_obj.subtitle}}</em><br/>{% endif %}
|
||||||
{% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %}
|
|
||||||
{% if scrobble.video.tv_series %}<em>{{scrobble.video.tv_series }}</em><br/>{% endif %}
|
|
||||||
{% if scrobble.sport_event %}<em>{{scrobble.sport_event.round.season.league}}</em><br/>{% endif %}
|
|
||||||
<small>{{scrobble.timestamp|naturaltime}}<br/>
|
<small>{{scrobble.timestamp|naturaltime}}<br/>
|
||||||
from {{scrobble.source}}</small>
|
from {{scrobble.source}}</small>
|
||||||
<div class="progress-bar" style="margin-right:5px;">
|
<div class="progress-bar" style="margin-right:5px;">
|
||||||
@ -233,6 +230,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>
|
||||||
@ -254,7 +257,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 %}
|
||||||
|
|||||||
@ -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">
|
||||||
@ -15,4 +16,4 @@
|
|||||||
{% block lists %}{% endblock %}
|
{% block lists %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
30
vrobbler/templates/scrobbles/chart_index.html
Normal file
30
vrobbler/templates/scrobbles/chart_index.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<h2>{{name}}</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Rank</th>
|
||||||
|
<th scope="col">{{media_type}}</th>
|
||||||
|
<th scope="col">Scrobbles</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for chartrecord in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{chartrecord.rank}}</td>
|
||||||
|
<td><a href="{{chartrecord.media_obj.get_absolute_url}}">{{chartrecord.media_obj}}</a></td>
|
||||||
|
<td>{{chartrecord.count}}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
|
|
||||||
{% 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
|
<div
|
||||||
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||||
<h1 class="h2">Dashboard</h1>
|
<h1 class="h2">Dashboard</h1>
|
||||||
|
|||||||
@ -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>
|
||||||
|
{% 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>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="container">
|
{% endblock %}
|
||||||
|
|
||||||
<ul>
|
|
||||||
{% for movie in object_list %}
|
|
||||||
<li>{{movie}}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
@ -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>
|
||||||
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="container">
|
{% endblock %}
|
||||||
<ul>
|
|
||||||
{% for movie in object_list %}
|
|
||||||
<li>{{movie}}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
@ -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}}
|
|
||||||
|
|
||||||
{% for scrobble in object.scrobble_set.all %}
|
<div class="row">
|
||||||
<ul>
|
<h2>{{object.tv_series}}</h2>
|
||||||
<li>{{scrobble}}</li>
|
<div class="col-md">
|
||||||
</ul>
|
<h3>Last scrobbles</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
{% endfor %}
|
<table class="table table-striped table-sm">
|
||||||
{% endblock %}
|
<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