Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7151646600 | |||
| 1d7cf965ef | |||
| 0a9279dbd4 | |||
| bf3479dbc7 | |||
| a99dca246b | |||
| f76aaf6a9c | |||
| ce1541bb2d | |||
| d34e56aa89 | |||
| 6316d4bead | |||
| 56e5728245 | |||
| 6ff170e169 | |||
| 86d1cf0d65 | |||
| a0101bf1ae | |||
| 457afdc9ef | |||
| d5bf6440b0 | |||
| 803ed7d8d7 | |||
| 93c4dd3d3b | |||
| ab728de75f | |||
| 04b7214795 | |||
| 479fee6a5c | |||
| 40a126cf8b | |||
| 83c02aa00f | |||
| 0f44df2b9b | |||
| 16d1dcc125 | |||
| 927d0be1b8 | |||
| f6b9245b8b | |||
| 39e035b460 | |||
| cf9da39967 |
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.10.0"
|
version = "0.11.4"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -5,18 +5,13 @@ import time_machine
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from music.aggregators import (
|
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
|
||||||
scrobble_counts,
|
|
||||||
top_artists,
|
|
||||||
top_tracks,
|
|
||||||
week_of_scrobbles,
|
|
||||||
)
|
|
||||||
from profiles.models import UserProfile
|
from profiles.models import UserProfile
|
||||||
from scrobbles.models import Scrobble
|
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):
|
||||||
@ -55,7 +50,7 @@ def test_week_of_scrobbles_data(client, mopidy_track_request_data):
|
|||||||
def test_top_tracks_by_day(client, mopidy_track_request_data):
|
def test_top_tracks_by_day(client, mopidy_track_request_data):
|
||||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
tops = top_tracks(user)
|
tops = live_charts(user)
|
||||||
assert tops[0].title == "Same in the End"
|
assert tops[0].title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
@ -63,7 +58,7 @@ def test_top_tracks_by_day(client, mopidy_track_request_data):
|
|||||||
def test_top_tracks_by_week(client, mopidy_track_request_data):
|
def test_top_tracks_by_week(client, mopidy_track_request_data):
|
||||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
tops = top_tracks(user, filter='week')
|
tops = live_charts(user, chart_period='week')
|
||||||
assert tops[0].title == "Same in the End"
|
assert tops[0].title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
@ -71,7 +66,7 @@ def test_top_tracks_by_week(client, mopidy_track_request_data):
|
|||||||
def test_top_tracks_by_month(client, mopidy_track_request_data):
|
def test_top_tracks_by_month(client, mopidy_track_request_data):
|
||||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
tops = top_tracks(user, filter='month')
|
tops = live_charts(user, chart_period='month')
|
||||||
assert tops[0].title == "Same in the End"
|
assert tops[0].title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
@ -79,7 +74,7 @@ def test_top_tracks_by_month(client, mopidy_track_request_data):
|
|||||||
def test_top_tracks_by_year(client, mopidy_track_request_data):
|
def test_top_tracks_by_year(client, mopidy_track_request_data):
|
||||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
tops = top_tracks(user, filter='year')
|
tops = live_charts(user, chart_period='year')
|
||||||
assert tops[0].title == "Same in the End"
|
assert tops[0].title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
@ -87,7 +82,7 @@ def test_top_tracks_by_year(client, mopidy_track_request_data):
|
|||||||
def test_top__artists_by_week(client, mopidy_track_request_data):
|
def test_top__artists_by_week(client, mopidy_track_request_data):
|
||||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
tops = top_artists(user, filter='week')
|
tops = live_charts(user, chart_period='week', media_type="Artist")
|
||||||
assert tops[0].name == "Sublime"
|
assert tops[0].name == "Sublime"
|
||||||
|
|
||||||
|
|
||||||
@ -95,7 +90,7 @@ def test_top__artists_by_week(client, mopidy_track_request_data):
|
|||||||
def test_top__artists_by_month(client, mopidy_track_request_data):
|
def test_top__artists_by_month(client, mopidy_track_request_data):
|
||||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
tops = top_artists(user, filter='month')
|
tops = live_charts(user, chart_period='month', media_type="Artist")
|
||||||
assert tops[0].name == "Sublime"
|
assert tops[0].name == "Sublime"
|
||||||
|
|
||||||
|
|
||||||
@ -103,5 +98,5 @@ def test_top__artists_by_month(client, mopidy_track_request_data):
|
|||||||
def test_top__artists_by_year(client, mopidy_track_request_data):
|
def test_top__artists_by_year(client, mopidy_track_request_data):
|
||||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
tops = top_artists(user, filter='year')
|
tops = live_charts(user, chart_period='year', media_type="Artist")
|
||||||
assert tops[0].name == "Sublime"
|
assert tops[0].name == "Sublime"
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import imdb
|
|
||||||
from mock import patch
|
|
||||||
|
|
||||||
from vrobbler.apps.scrobbles.imdb import lookup_video_from_imdb
|
from vrobbler.apps.scrobbles.imdb import lookup_video_from_imdb
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Need to sort out third party API testing")
|
||||||
def test_lookup_imdb_bad_id(caplog):
|
def test_lookup_imdb_bad_id(caplog):
|
||||||
data = lookup_video_from_imdb('3409324')
|
data = lookup_video_from_imdb('3409324')
|
||||||
assert data is None
|
assert data is None
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
14
vrobbler/apps/books/api/serializers.py
Normal file
14
vrobbler/apps/books/api/serializers.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from books.models import Author, Book
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Author
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class BookSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = "__all__"
|
||||||
19
vrobbler/apps/books/api/views.py
Normal file
19
vrobbler/apps/books/api/views.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from books.api.serializers import (
|
||||||
|
AuthorSerializer,
|
||||||
|
BookSerializer,
|
||||||
|
)
|
||||||
|
from books.models import Author, Book
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Author.objects.all().order_by('-created')
|
||||||
|
serializer_class = AuthorSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class BookViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Book.objects.all().order_by('-created')
|
||||||
|
serializer_class = BookSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
@ -8,7 +8,8 @@ from django.urls import reverse
|
|||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
from vrobbler.apps.books.utils import lookup_book_from_openlibrary
|
from books.utils import lookup_book_from_openlibrary
|
||||||
|
from scrobbles.utils import get_scrobbles_for_media
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -66,3 +67,7 @@ class Book(ScrobblableMixin):
|
|||||||
logger.warn(f"{self} has no pages, no completion percentage")
|
logger.warn(f"{self} has no pages, no completion percentage")
|
||||||
return 0
|
return 0
|
||||||
return int(self.pages * (self.COMPLETION_PERCENT / 100))
|
return int(self.pages * (self.COMPLETION_PERCENT / 100))
|
||||||
|
|
||||||
|
def progress_for_user(self, user: User) -> int:
|
||||||
|
last_scrobble = get_scrobbles_for_media(self, user).last()
|
||||||
|
return int((last_scrobble.book_pages_read / self.pages) * 100)
|
||||||
|
|||||||
@ -1,22 +1,14 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Optional
|
from typing import List
|
||||||
|
|
||||||
import pytz
|
from django.apps import apps
|
||||||
from django.db.models import Count, Q, Sum
|
from django.db.models import Count, Q, QuerySet
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from music.models import Artist, Track
|
from music.models import Artist, Track
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
from videos.models import Video
|
from videos.models import Video
|
||||||
from vrobbler.apps.profiles.utils import now_user_timezone
|
from vrobbler.apps.profiles.utils import now_user_timezone
|
||||||
|
|
||||||
NOW = timezone.now()
|
|
||||||
START_OF_TODAY = datetime.combine(NOW.date(), datetime.min.time(), NOW.tzinfo)
|
|
||||||
STARTING_DAY_OF_CURRENT_WEEK = NOW.date() - timedelta(
|
|
||||||
days=NOW.today().isoweekday() % 7
|
|
||||||
)
|
|
||||||
STARTING_DAY_OF_CURRENT_MONTH = NOW.date().replace(day=1)
|
|
||||||
STARTING_DAY_OF_CURRENT_YEAR = NOW.date().replace(month=1, day=1)
|
|
||||||
|
|
||||||
|
|
||||||
def scrobble_counts(user=None):
|
def scrobble_counts(user=None):
|
||||||
|
|
||||||
@ -55,7 +47,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 +57,8 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
|
|||||||
now = now_user_timezone(user.profile)
|
now = now_user_timezone(user.profile)
|
||||||
user_filter = Q(user=user)
|
user_filter = Q(user=user)
|
||||||
|
|
||||||
start_of_today = datetime.combine(
|
if not start:
|
||||||
now.date(), datetime.min.time(), now.tzinfo
|
start = datetime.combine(now.date(), datetime.min.time(), now.tzinfo)
|
||||||
)
|
|
||||||
|
|
||||||
scrobble_day_dict = {}
|
scrobble_day_dict = {}
|
||||||
base_qs = Scrobble.objects.filter(user_filter, played_to_completion=True)
|
base_qs = Scrobble.objects.filter(user_filter, played_to_completion=True)
|
||||||
@ -77,13 +70,13 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
|
|||||||
media_filter = Q(video__video_type=Video.VideoType.TV_EPISODE)
|
media_filter = Q(video__video_type=Video.VideoType.TV_EPISODE)
|
||||||
|
|
||||||
for day in range(6, -1, -1):
|
for day in range(6, -1, -1):
|
||||||
start = start_of_today - timedelta(days=day)
|
start_day = start - timedelta(days=day)
|
||||||
end = datetime.combine(start, datetime.max.time(), now.tzinfo)
|
end = datetime.combine(start_day, datetime.max.time(), now.tzinfo)
|
||||||
day_of_week = start.strftime('%A')
|
day_of_week = start_day.strftime('%A')
|
||||||
|
|
||||||
scrobble_day_dict[day_of_week] = base_qs.filter(
|
scrobble_day_dict[day_of_week] = base_qs.filter(
|
||||||
media_filter,
|
media_filter,
|
||||||
timestamp__gte=start,
|
timestamp__gte=start_day,
|
||||||
timestamp__lte=end,
|
timestamp__lte=end,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
).count()
|
).count()
|
||||||
@ -91,58 +84,64 @@ def week_of_scrobbles(user=None, media: str = 'tracks') -> dict[str, int]:
|
|||||||
return scrobble_day_dict
|
return scrobble_day_dict
|
||||||
|
|
||||||
|
|
||||||
def top_tracks(
|
def live_charts(
|
||||||
user: "User", filter: str = "today", limit: int = 15
|
user: "User",
|
||||||
) -> List["Track"]:
|
media_type: str = "Track",
|
||||||
|
chart_period: str = "all",
|
||||||
|
limit: int = 15,
|
||||||
|
) -> QuerySet:
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
tzinfo = now.tzinfo
|
||||||
|
now = now.date()
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
now = now_user_timezone(user.profile)
|
now = now_user_timezone(user.profile)
|
||||||
|
tzinfo = now.tzinfo
|
||||||
|
|
||||||
start_of_today = datetime.combine(
|
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
|
||||||
now.date(), datetime.min.time(), now.tzinfo
|
start_day_of_week = now - timedelta(days=now.today().isoweekday() % 7)
|
||||||
)
|
start_day_of_month = now.replace(day=1)
|
||||||
starting_day_of_current_week = now.date() - timedelta(
|
start_day_of_year = now.replace(month=1, day=1)
|
||||||
days=now.today().isoweekday() % 7
|
|
||||||
)
|
|
||||||
starting_day_of_current_month = now.date().replace(day=1)
|
|
||||||
starting_day_of_current_year = now.date().replace(month=1, day=1)
|
|
||||||
|
|
||||||
time_filter = Q(scrobble__timestamp__gte=start_of_today)
|
media_model = apps.get_model(app_label='music', model_name=media_type)
|
||||||
if filter == "week":
|
|
||||||
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_week)
|
period_queries = {
|
||||||
if filter == "month":
|
'today': {'scrobble__timestamp__gte': start_of_today},
|
||||||
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_month)
|
'week': {'scrobble__timestamp__gte': start_day_of_week},
|
||||||
if filter == "year":
|
'month': {'scrobble__timestamp__gte': start_day_of_month},
|
||||||
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_year)
|
'year': {'scrobble__timestamp__gte': start_day_of_year},
|
||||||
|
'all': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
time_filter = Q()
|
||||||
|
completion_filter = Q(
|
||||||
|
scrobble__user=user, scrobble__played_to_completion=True
|
||||||
|
)
|
||||||
|
user_filter = Q(scrobble__user=user)
|
||||||
|
count_field = "scrobble"
|
||||||
|
|
||||||
|
if media_type == "Artist":
|
||||||
|
for period, query_dict in period_queries.items():
|
||||||
|
period_queries[period] = {
|
||||||
|
"track__" + k: v for k, v in query_dict.items()
|
||||||
|
}
|
||||||
|
completion_filter = Q(
|
||||||
|
track__scrobble__user=user,
|
||||||
|
track__scrobble__played_to_completion=True,
|
||||||
|
)
|
||||||
|
count_field = "track__scrobble"
|
||||||
|
user_filter = Q(track__scrobble__user=user)
|
||||||
|
|
||||||
|
time_filter = Q(**period_queries[chart_period])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Track.objects.filter(time_filter)
|
media_model.objects.filter(user_filter, time_filter)
|
||||||
.annotate(num_scrobbles=Count("scrobble", distinct=True))
|
.annotate(
|
||||||
.order_by("-num_scrobbles")[:limit]
|
num_scrobbles=Count(
|
||||||
)
|
count_field,
|
||||||
|
filter=completion_filter,
|
||||||
|
distinct=True,
|
||||||
def top_artists(
|
)
|
||||||
user: "User", filter: str = "today", limit: int = 15
|
|
||||||
) -> List["Artist"]:
|
|
||||||
time_filter = Q(track__scrobble__timestamp__gte=START_OF_TODAY)
|
|
||||||
if filter == "week":
|
|
||||||
time_filter = Q(
|
|
||||||
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK
|
|
||||||
)
|
)
|
||||||
if filter == "month":
|
|
||||||
time_filter = Q(
|
|
||||||
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH
|
|
||||||
)
|
|
||||||
if filter == "year":
|
|
||||||
time_filter = Q(
|
|
||||||
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
Artist.objects.filter(time_filter)
|
|
||||||
.annotate(num_scrobbles=Count("track__scrobble", distinct=True))
|
|
||||||
.order_by("-num_scrobbles")[:limit]
|
.order_by("-num_scrobbles")[:limit]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
0
vrobbler/apps/music/api/__init__.py
Normal file
0
vrobbler/apps/music/api/__init__.py
Normal file
20
vrobbler/apps/music/api/serializers.py
Normal file
20
vrobbler/apps/music/api/serializers.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from music.models import Album, Artist, Track
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Artist
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Album
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class TrackSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Track
|
||||||
|
fields = "__all__"
|
||||||
26
vrobbler/apps/music/api/views.py
Normal file
26
vrobbler/apps/music/api/views.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from music.api.serializers import (
|
||||||
|
TrackSerializer,
|
||||||
|
ArtistSerializer,
|
||||||
|
AlbumSerializer,
|
||||||
|
)
|
||||||
|
from music.models import Artist, Album, Track
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Artist.objects.all().order_by('-created')
|
||||||
|
serializer_class = ArtistSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Album.objects.all().order_by('-created')
|
||||||
|
serializer_class = AlbumSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class TrackViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Track.objects.all().order_by('-created')
|
||||||
|
serializer_class = TrackSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
@ -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
|
import logging
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
from urllib.request import urlopen
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile, File
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
from scrobbles.theaudiodb import lookup_artist_from_tadb
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -18,7 +21,11 @@ BNULL = {"blank": True, "null": True}
|
|||||||
class Artist(TimeStampedModel):
|
class Artist(TimeStampedModel):
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
biography = models.TextField(**BNULL)
|
||||||
|
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
|
||||||
|
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
|
||||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [['name', 'musicbrainz_id']]
|
unique_together = [['name', 'musicbrainz_id']]
|
||||||
@ -53,6 +60,22 @@ class Artist(TimeStampedModel):
|
|||||||
|
|
||||||
return ChartRecord.objects.filter(track__artist=self).order_by('-year')
|
return ChartRecord.objects.filter(track__artist=self).order_by('-year')
|
||||||
|
|
||||||
|
def fix_metadata(self):
|
||||||
|
tadb_info = lookup_artist_from_tadb(self.name)
|
||||||
|
if not tadb_info:
|
||||||
|
logger.warn(f"No response from TADB for artist {self.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.biography = tadb_info['biography']
|
||||||
|
self.theaudiodb_genre = tadb_info['genre']
|
||||||
|
self.theaudiodb_mood = tadb_info['mood']
|
||||||
|
|
||||||
|
img_temp = NamedTemporaryFile(delete=True)
|
||||||
|
img_temp.write(urlopen(tadb_info['thumb_url']).read())
|
||||||
|
img_temp.flush()
|
||||||
|
img_filename = f"{self.name}_{self.uuid}.jpg"
|
||||||
|
self.thumbnail.save(img_filename, File(img_temp))
|
||||||
|
|
||||||
|
|
||||||
class Album(TimeStampedModel):
|
class Album(TimeStampedModel):
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
@ -67,6 +90,24 @@ class Album(TimeStampedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("music:album_detail", kwargs={'slug': self.uuid})
|
||||||
|
|
||||||
|
def scrobbles(self):
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
return Scrobble.objects.filter(
|
||||||
|
track__in=self.track_set.all()
|
||||||
|
).order_by('-timestamp')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tracks(self):
|
||||||
|
return (
|
||||||
|
self.track_set.all()
|
||||||
|
.annotate(scrobble_count=models.Count('scrobble'))
|
||||||
|
.order_by('-scrobble_count')
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_artist(self):
|
def primary_artist(self):
|
||||||
return self.artists.first()
|
return self.artists.first()
|
||||||
@ -181,10 +222,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
vrobbler/apps/music/serializers.py
Normal file
1
vrobbler/apps/music/serializers.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
@ -6,6 +6,11 @@ app_name = 'music'
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('albums/', views.AlbumListView.as_view(), name='albums_list'),
|
path('albums/', views.AlbumListView.as_view(), name='albums_list'),
|
||||||
|
path(
|
||||||
|
'album/<slug:slug>/',
|
||||||
|
views.AlbumDetailView.as_view(),
|
||||||
|
name='album_detail',
|
||||||
|
),
|
||||||
path("tracks/", views.TrackListView.as_view(), name='tracks_list'),
|
path("tracks/", views.TrackListView.as_view(), name='tracks_list'),
|
||||||
path(
|
path(
|
||||||
'tracks/<slug:slug>/',
|
'tracks/<slug:slug>/',
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from musicbrainzngs.caa import musicbrainz
|
||||||
|
|
||||||
from scrobbles.musicbrainz import (
|
from scrobbles.musicbrainz import (
|
||||||
lookup_album_dict_from_mb,
|
lookup_album_dict_from_mb,
|
||||||
lookup_artist_from_mb,
|
lookup_artist_from_mb,
|
||||||
|
lookup_track_from_mb,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -20,20 +23,20 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
|
|||||||
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
||||||
if 'featuring' in name.lower():
|
if 'featuring' in name.lower():
|
||||||
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
|
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
|
||||||
|
if '&' in name.lower():
|
||||||
|
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||||
|
|
||||||
artist_dict = lookup_artist_from_mb(name)
|
artist_dict = lookup_artist_from_mb(name)
|
||||||
mbid = mbid or artist_dict['id']
|
mbid = mbid or artist_dict['id']
|
||||||
|
|
||||||
logger.debug(f'Looking up artist {name} and mbid: {mbid}')
|
logger.debug(f'Looking up artist {name} and mbid: {mbid}')
|
||||||
artist, created = Artist.objects.get_or_create(
|
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||||
name=name, musicbrainz_id=mbid
|
if not artist:
|
||||||
)
|
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
|
||||||
|
logger.debug(
|
||||||
logger.debug(f"Cleaning artist {name} with {artist_dict['name']}")
|
f"Created artist {artist.name} ({artist.musicbrainz_id}) "
|
||||||
# Clean up bad names in our DB with MB names
|
)
|
||||||
# if artist.name != artist_dict["name"]:
|
artist.fix_metadata()
|
||||||
# artist.name = artist_dict["name"]
|
|
||||||
# artist.save(update_fields=["name"])
|
|
||||||
|
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
@ -77,23 +80,22 @@ def get_or_create_album(name: str, artist: Artist, mbid: str = None) -> Album:
|
|||||||
|
|
||||||
def get_or_create_track(
|
def get_or_create_track(
|
||||||
title: str,
|
title: str,
|
||||||
mbid: str,
|
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
album: Album,
|
album: Album,
|
||||||
|
mbid: str = None,
|
||||||
run_time=None,
|
run_time=None,
|
||||||
run_time_ticks=None,
|
run_time_ticks=None,
|
||||||
) -> Track:
|
) -> Track:
|
||||||
track = None
|
track = None
|
||||||
if mbid:
|
if not mbid:
|
||||||
track = Track.objects.filter(
|
mbid = lookup_track_from_mb(
|
||||||
musicbrainz_id=mbid,
|
title,
|
||||||
).first()
|
artist.musicbrainz_id,
|
||||||
if not track:
|
album.musicbrainz_id,
|
||||||
track = Track.objects.filter(
|
)['id']
|
||||||
title=title, artist=artist, album=album
|
|
||||||
).first()
|
track = Track.objects.filter(musicbrainz_id=mbid).first()
|
||||||
|
|
||||||
# TODO Can we look up mbid for tracks?
|
|
||||||
if not track:
|
if not track:
|
||||||
track = Track.objects.create(
|
track = Track.objects.create(
|
||||||
title=title,
|
title=title,
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
from django.views import generic
|
from django.views import generic
|
||||||
from music.models import Track, Artist, Album
|
from music.models import Album, Artist, Track
|
||||||
|
from scrobbles.models import ChartRecord
|
||||||
from scrobbles.stats import get_scrobble_count_qs
|
from scrobbles.stats import get_scrobble_count_qs
|
||||||
|
|
||||||
|
|
||||||
@ -16,6 +19,14 @@ class TrackDetailView(generic.DetailView):
|
|||||||
model = Track
|
model = Track
|
||||||
slug_field = 'uuid'
|
slug_field = 'uuid'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
context_data['charts'] = ChartRecord.objects.filter(
|
||||||
|
track=self.object, rank__in=[1, 2, 3]
|
||||||
|
)
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
class ArtistListView(generic.ListView):
|
class ArtistListView(generic.ListView):
|
||||||
model = Artist
|
model = Artist
|
||||||
@ -28,6 +39,25 @@ class ArtistDetailView(generic.DetailView):
|
|||||||
model = Artist
|
model = Artist
|
||||||
slug_field = 'uuid'
|
slug_field = 'uuid'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
context_data['charts'] = ChartRecord.objects.filter(
|
||||||
|
artist=self.object, rank__in=[1, 2, 3]
|
||||||
|
)
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
class AlbumListView(generic.ListView):
|
class AlbumListView(generic.ListView):
|
||||||
model = Album
|
model = Album
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumDetailView(generic.DetailView):
|
||||||
|
model = Album
|
||||||
|
slug_field = 'uuid'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
# context_data['charts'] = ChartRecord.objects.filter(
|
||||||
|
# track__album=self.object, rank__in=[1, 2, 3]
|
||||||
|
# )
|
||||||
|
return context_data
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
1
vrobbler/apps/profiles/api/__init__.py
Normal file
1
vrobbler/apps/profiles/api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
18
vrobbler/apps/profiles/api/serializers.py
Normal file
18
vrobbler/apps/profiles/api/serializers.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
from profiles.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
exclude = ('password',)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
exclude = ('lastfm_password',)
|
||||||
28
vrobbler/apps/profiles/api/views.py
Normal file
28
vrobbler/apps/profiles/api/views.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from profiles.api.serializers import UserSerializer, UserProfileSerializer
|
||||||
|
from profiles.models import UserProfile
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint that allows users to be viewed or edited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = User.objects.all().order_by('-date_joined')
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint that allows users to be viewed or edited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = UserProfile.objects.all().order_by('-created')
|
||||||
|
serializer_class = UserProfileSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
@ -47,6 +47,7 @@ class ChartRecordAdmin(admin.ModelAdmin):
|
|||||||
list_display = (
|
list_display = (
|
||||||
"user",
|
"user",
|
||||||
"rank",
|
"rank",
|
||||||
|
"count",
|
||||||
"year",
|
"year",
|
||||||
"week",
|
"week",
|
||||||
"month",
|
"month",
|
||||||
|
|||||||
0
vrobbler/apps/scrobbles/api/__init__.py
Normal file
0
vrobbler/apps/scrobbles/api/__init__.py
Normal file
33
vrobbler/apps/scrobbles/api/serializers.py
Normal file
33
vrobbler/apps/scrobbles/api/serializers.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from scrobbles.models import (
|
||||||
|
AudioScrobblerTSVImport,
|
||||||
|
KoReaderImport,
|
||||||
|
LastFmImport,
|
||||||
|
Scrobble,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Scrobble
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class KoReaderImportSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = KoReaderImport
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerTSVImportSerializer(
|
||||||
|
serializers.HyperlinkedModelSerializer
|
||||||
|
):
|
||||||
|
class Meta:
|
||||||
|
model = AudioScrobblerTSVImport
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmImportSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = LastFmImport
|
||||||
|
fields = "__all__"
|
||||||
49
vrobbler/apps/scrobbles/api/views.py
Normal file
49
vrobbler/apps/scrobbles/api/views.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
from scrobbles.api.serializers import (
|
||||||
|
AudioScrobblerTSVImportSerializer,
|
||||||
|
KoReaderImportSerializer,
|
||||||
|
LastFmImportSerializer,
|
||||||
|
ScrobbleSerializer,
|
||||||
|
)
|
||||||
|
from scrobbles.models import (
|
||||||
|
AudioScrobblerTSVImport,
|
||||||
|
KoReaderImport,
|
||||||
|
Scrobble,
|
||||||
|
LastFmImport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Scrobble.objects.all().order_by('-timestamp')
|
||||||
|
serializer_class = ScrobbleSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class KoReaderImportViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = KoReaderImport.objects.all().order_by('-created')
|
||||||
|
serializer_class = KoReaderImportSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerTSVImportViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = AudioScrobblerTSVImport.objects.all().order_by('-created')
|
||||||
|
serializer_class = AudioScrobblerTSVImportSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmImportViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LastFmImport.objects.all().order_by('-created')
|
||||||
|
serializer_class = LastFmImportSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
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
|
import logging
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from books.models import Book
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from music.models import Artist, Track
|
from music.models import Artist, Track
|
||||||
from books.models import Book
|
|
||||||
from podcasts.models import Episode
|
from podcasts.models import Episode
|
||||||
from profiles.utils import now_user_timezone
|
|
||||||
from scrobbles.lastfm import LastFM
|
from scrobbles.lastfm import LastFM
|
||||||
from scrobbles.utils import check_scrobble_for_finish
|
from scrobbles.utils import check_scrobble_for_finish
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
from videos.models import Series, Video
|
from videos.models import Series, Video
|
||||||
|
|
||||||
|
from vrobbler.apps.scrobbles.stats import build_charts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -34,6 +36,24 @@ class BaseFileImportMixin(TimeStampedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Scrobble import {self.id}"
|
return f"Scrobble import {self.id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def human_start(self):
|
||||||
|
start = "Unknown"
|
||||||
|
if self.processing_started:
|
||||||
|
start = self.processing_started.strftime('%B %d, %Y at %H:%M')
|
||||||
|
return start
|
||||||
|
|
||||||
|
@property
|
||||||
|
def import_type(self) -> str:
|
||||||
|
class_name = self.__class__.__name__
|
||||||
|
if class_name == 'AudioscrobblerTSVImport':
|
||||||
|
return "Audioscrobbler"
|
||||||
|
if class_name == 'KoReaderImport':
|
||||||
|
return "KoReader"
|
||||||
|
if self.__class__.__name__ == 'LastFMImport':
|
||||||
|
return "LastFM"
|
||||||
|
return "Generic"
|
||||||
|
|
||||||
def process(self, force=False):
|
def process(self, force=False):
|
||||||
logger.warning("Process not implemented")
|
logger.warning("Process not implemented")
|
||||||
|
|
||||||
@ -98,6 +118,14 @@ class KoReaderImport(BaseFileImportMixin):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "KOReader Import"
|
verbose_name = "KOReader Import"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"KoReader import on {self.human_start}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
'scrobbles:koreader-import-detail', kwargs={'slug': self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
def get_path(instance, filename):
|
def get_path(instance, filename):
|
||||||
extension = filename.split('.')[-1]
|
extension = filename.split('.')[-1]
|
||||||
uuid = instance.uuid
|
uuid = instance.uuid
|
||||||
@ -126,6 +154,14 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "AudioScrobbler TSV Import"
|
verbose_name = "AudioScrobbler TSV Import"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Audioscrobbler import on {self.human_start}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
'scrobbles:tsv-import-detail', kwargs={'slug': self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
def get_path(instance, filename):
|
def get_path(instance, filename):
|
||||||
extension = filename.split('.')[-1]
|
extension = filename.split('.')[-1]
|
||||||
uuid = instance.uuid
|
uuid = instance.uuid
|
||||||
@ -158,6 +194,14 @@ class LastFmImport(BaseFileImportMixin):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Last.FM Import"
|
verbose_name = "Last.FM Import"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"LastFM import on {self.human_start}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
'scrobbles:lastfm-import-detail', kwargs={'slug': self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
def process(self, import_all=False):
|
def process(self, import_all=False):
|
||||||
"""Import scrobbles found on LastFM"""
|
"""Import scrobbles found on LastFM"""
|
||||||
if self.processed_finished:
|
if self.processed_finished:
|
||||||
@ -203,6 +247,7 @@ class ChartRecord(TimeStampedModel):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
rank = models.IntegerField()
|
rank = models.IntegerField()
|
||||||
|
count = models.IntegerField(default=0)
|
||||||
year = models.IntegerField(default=timezone.now().year)
|
year = models.IntegerField(default=timezone.now().year)
|
||||||
month = models.IntegerField(**BNULL)
|
month = models.IntegerField(**BNULL)
|
||||||
week = models.IntegerField(**BNULL)
|
week = models.IntegerField(**BNULL)
|
||||||
@ -219,6 +264,8 @@ class ChartRecord(TimeStampedModel):
|
|||||||
media_obj = self.video
|
media_obj = self.video
|
||||||
if self.track:
|
if self.track:
|
||||||
media_obj = self.track
|
media_obj = self.track
|
||||||
|
if self.artist:
|
||||||
|
media_obj = self.artist
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -265,7 +312,42 @@ class ChartRecord(TimeStampedModel):
|
|||||||
return period
|
return period
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"#{self.rank} in {self.period} - {self.media_obj}"
|
title = f"#{self.rank} in {self.period}"
|
||||||
|
if self.day or self.week:
|
||||||
|
title = f"#{self.rank} on {self.period}"
|
||||||
|
return title
|
||||||
|
|
||||||
|
def link(self):
|
||||||
|
get_params = f"?date={self.year}"
|
||||||
|
if self.week:
|
||||||
|
get_params = get_params = get_params + f"-W{self.week}"
|
||||||
|
if self.month:
|
||||||
|
get_params = get_params = get_params + f"-{self.month}"
|
||||||
|
if self.day:
|
||||||
|
get_params = get_params = get_params + f"-{self.day}"
|
||||||
|
if self.artist:
|
||||||
|
get_params = get_params + "&media=Artist"
|
||||||
|
return reverse('scrobbles:charts-home') + get_params
|
||||||
|
|
||||||
|
@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):
|
||||||
|
|||||||
@ -90,16 +90,18 @@ def lookup_artist_from_mb(artist_name: str) -> str:
|
|||||||
return top_result
|
return top_result
|
||||||
|
|
||||||
|
|
||||||
def lookup_track_from_mb(artist_name: str) -> str:
|
def lookup_track_from_mb(
|
||||||
|
track_name: str, artist_mbid: str, album_mbid: str
|
||||||
|
) -> str:
|
||||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||||
|
|
||||||
top_result = musicbrainzngs.search_recordings(artist=artist_name)[
|
top_result = musicbrainzngs.search_recordings(
|
||||||
'artist-list'
|
query=track_name, artist=artist_mbid, release=album_mbid
|
||||||
][0]
|
)['recording-list'][0]
|
||||||
score = int(top_result.get('ext:score'))
|
score = int(top_result.get('ext:score'))
|
||||||
if score < 85:
|
if score < 85:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Artist lookup score below 85 threshold",
|
"Track lookup score below 85 threshold",
|
||||||
extra={"result": top_result},
|
extra={"result": top_result},
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -89,10 +89,6 @@ def mopidy_scrobble_track(
|
|||||||
"mopidy_status": data_dict.get("status"),
|
"mopidy_status": data_dict.get("status"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
|
|
||||||
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
|
|
||||||
track.save()
|
|
||||||
|
|
||||||
scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
|
scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
|
||||||
|
|
||||||
return scrobble
|
return scrobble
|
||||||
@ -130,7 +126,7 @@ def jellyfin_scrobble_track(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
|
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
|
||||||
if not data_dict.get("PlaybackPositionTicks") or null_position_on_progress:
|
if null_position_on_progress:
|
||||||
logger.error("No playback position tick from Jellyfin, aborting")
|
logger.error("No playback position tick from Jellyfin, aborting")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -152,7 +148,6 @@ def jellyfin_scrobble_track(
|
|||||||
)
|
)
|
||||||
track = get_or_create_track(
|
track = get_or_create_track(
|
||||||
title=data_dict.get("Name"),
|
title=data_dict.get("Name"),
|
||||||
mbid=data_dict.get(JELLYFIN_POST_KEYS["TRACK_MB_ID"]),
|
|
||||||
artist=artist,
|
artist=artist,
|
||||||
album=album,
|
album=album,
|
||||||
run_time_ticks=run_time_ticks,
|
run_time_ticks=run_time_ticks,
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from scrobbles.models import Scrobble, AudioScrobblerTSVImport
|
|
||||||
|
|
||||||
|
|
||||||
class AudioScrobblerTSVImportSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = AudioScrobblerTSVImport
|
|
||||||
fields = ('tsv_file',)
|
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Scrobble
|
|
||||||
fields = "__all__"
|
|
||||||
@ -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,14 @@ def build_charts(
|
|||||||
'user': user,
|
'user': user,
|
||||||
}
|
}
|
||||||
chart_record['rank'] = ranks[result.scrobble_count]
|
chart_record['rank'] = ranks[result.scrobble_count]
|
||||||
|
chart_record['count'] = result.scrobble_count
|
||||||
if model_str == 'Track':
|
if model_str == 'Track':
|
||||||
chart_record['track'] = result
|
chart_record['track'] = result
|
||||||
|
if model_str == 'Video':
|
||||||
|
chart_record['video'] = result
|
||||||
|
if model_str == 'Artist':
|
||||||
|
chart_record['artist'] = result
|
||||||
chart_records.append(ChartRecord(**chart_record))
|
chart_records.append(ChartRecord(**chart_record))
|
||||||
ChartRecord.objects.bulk_create(chart_records, ignore_conflicts=True)
|
ChartRecord.objects.bulk_create(
|
||||||
|
chart_records, ignore_conflicts=True, batch_size=500
|
||||||
|
)
|
||||||
|
|||||||
34
vrobbler/apps/scrobbles/theaudiodb.py
Normal file
34
vrobbler/apps/scrobbles/theaudiodb.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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)
|
||||||
|
if results['artists']:
|
||||||
|
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
|
||||||
@ -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'),
|
||||||
|
|||||||
@ -4,7 +4,21 @@ from scrobbles import views
|
|||||||
app_name = 'scrobbles'
|
app_name = 'scrobbles'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.scrobble_endpoint, name='api-list'),
|
path(
|
||||||
|
'manual/imdb/',
|
||||||
|
views.ManualScrobbleView.as_view(),
|
||||||
|
name='imdb-manual-scrobble',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'manual/audioscrobbler/',
|
||||||
|
views.AudioScrobblerImportCreateView.as_view(),
|
||||||
|
name='audioscrobbler-file-upload',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'manual/koreader/',
|
||||||
|
views.KoReaderImportCreateView.as_view(),
|
||||||
|
name='koreader-file-upload',
|
||||||
|
),
|
||||||
path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
|
path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
|
||||||
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
|
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
|
||||||
path(
|
path(
|
||||||
@ -12,8 +26,45 @@ 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(
|
||||||
|
'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,10 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from dateutil.parser import ParserError, parse
|
from dateutil.parser import ParserError, parse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
def convert_to_seconds(run_time: str) -> int:
|
def convert_to_seconds(run_time: str) -> int:
|
||||||
@ -103,3 +107,11 @@ def check_scrobble_for_finish(
|
|||||||
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
|
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
|
||||||
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
|
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
|
||||||
scrobble.save(update_fields=['scrobble_log'])
|
scrobble.save(update_fields=['scrobble_log'])
|
||||||
|
|
||||||
|
|
||||||
|
def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
if media_obj.__class__.__name__ == 'Book':
|
||||||
|
media_query = models.Q(book=media_obj)
|
||||||
|
return Scrobble.objects.filter(media_query, user=user)
|
||||||
|
|||||||
@ -1,24 +1,23 @@
|
|||||||
|
import calendar
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
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.db.models.query import QuerySet
|
||||||
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
|
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.generic import FormView
|
from django.views.generic import DetailView, FormView, TemplateView
|
||||||
from django.views.generic.edit import CreateView
|
from django.views.generic.edit import CreateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from music.aggregators import (
|
from music.aggregators import scrobble_counts, week_of_scrobbles
|
||||||
scrobble_counts,
|
|
||||||
top_artists,
|
|
||||||
top_tracks,
|
|
||||||
week_of_scrobbles,
|
|
||||||
)
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import (
|
from rest_framework.decorators import (
|
||||||
api_view,
|
api_view,
|
||||||
@ -28,6 +27,7 @@ from rest_framework.decorators import (
|
|||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from scrobbles.api import serializers
|
||||||
from scrobbles.constants import (
|
from scrobbles.constants import (
|
||||||
JELLYFIN_AUDIO_ITEM_TYPES,
|
JELLYFIN_AUDIO_ITEM_TYPES,
|
||||||
JELLYFIN_VIDEO_ITEM_TYPES,
|
JELLYFIN_VIDEO_ITEM_TYPES,
|
||||||
@ -37,6 +37,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,
|
||||||
@ -49,10 +50,6 @@ from scrobbles.scrobblers import (
|
|||||||
mopidy_scrobble_podcast,
|
mopidy_scrobble_podcast,
|
||||||
mopidy_scrobble_track,
|
mopidy_scrobble_track,
|
||||||
)
|
)
|
||||||
from scrobbles.serializers import (
|
|
||||||
AudioScrobblerTSVImportSerializer,
|
|
||||||
ScrobbleSerializer,
|
|
||||||
)
|
|
||||||
from scrobbles.tasks import (
|
from scrobbles.tasks import (
|
||||||
process_koreader_import,
|
process_koreader_import,
|
||||||
process_lastfm_import,
|
process_lastfm_import,
|
||||||
@ -60,6 +57,8 @@ from scrobbles.tasks import (
|
|||||||
)
|
)
|
||||||
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
|
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
|
||||||
|
|
||||||
|
from vrobbler.apps.music.aggregators import live_charts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -69,17 +68,7 @@ class RecentScrobbleList(ListView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
data = super().get_context_data(**kwargs)
|
data = super().get_context_data(**kwargs)
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
now = timezone.now()
|
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
if user.profile:
|
|
||||||
timezone.activate(pytz.timezone(user.profile.timezone))
|
|
||||||
now = timezone.localtime(timezone.now())
|
|
||||||
data['now_playing_list'] = Scrobble.objects.filter(
|
|
||||||
in_progress=True,
|
|
||||||
is_paused=False,
|
|
||||||
timestamp__lte=now,
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
completed_for_user = Scrobble.objects.filter(
|
completed_for_user = Scrobble.objects.filter(
|
||||||
played_to_completion=True, user=user
|
played_to_completion=True, user=user
|
||||||
@ -95,14 +84,11 @@ class RecentScrobbleList(ListView):
|
|||||||
data['sport_scrobble_list'] = completed_for_user.filter(
|
data['sport_scrobble_list'] = completed_for_user.filter(
|
||||||
sport_event__isnull=False
|
sport_event__isnull=False
|
||||||
).order_by('-timestamp')[:15]
|
).order_by('-timestamp')[:15]
|
||||||
|
data['active_imports'] = AudioScrobblerTSVImport.objects.filter(
|
||||||
# data['top_daily_tracks'] = top_tracks()
|
processing_started__isnull=False,
|
||||||
data['top_weekly_tracks'] = top_tracks(user, filter='week')
|
processed_finished__isnull=True,
|
||||||
data['top_monthly_tracks'] = top_tracks(user, filter='month')
|
user=self.request.user,
|
||||||
|
)
|
||||||
# data['top_daily_artists'] = top_artists()
|
|
||||||
data['top_weekly_artists'] = top_artists(user, filter='week')
|
|
||||||
data['top_monthly_artists'] = top_artists(user, filter='month')
|
|
||||||
|
|
||||||
data["weekly_data"] = week_of_scrobbles(user=user)
|
data["weekly_data"] = week_of_scrobbles(user=user)
|
||||||
|
|
||||||
@ -117,6 +103,57 @@ class RecentScrobbleList(ListView):
|
|||||||
).order_by('-timestamp')[:15]
|
).order_by('-timestamp')[:15]
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleImportListView(TemplateView):
|
||||||
|
template_name = "scrobbles/import_list.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
context_data['object_list'] = []
|
||||||
|
|
||||||
|
context_data["tsv_imports"] = AudioScrobblerTSVImport.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
).order_by('-processing_started')
|
||||||
|
context_data["koreader_imports"] = KoReaderImport.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
).order_by('-processing_started')
|
||||||
|
context_data["lastfm_imports"] = LastFmImport.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
).order_by('-processing_started')
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
|
class BaseScrobbleImportDetailView(DetailView):
|
||||||
|
slug_field = 'uuid'
|
||||||
|
template_name = "scrobbles/import_detail.html"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
title = "Generic Scrobble Import"
|
||||||
|
if self.model == KoReaderImport:
|
||||||
|
title = "KoReader Import"
|
||||||
|
if self.model == AudioScrobblerTSVImport:
|
||||||
|
title = "Audioscrobbler TSV Import"
|
||||||
|
if self.model == LastFmImport:
|
||||||
|
title = "LastFM Import"
|
||||||
|
context_data['title'] = title
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleKoReaderImportDetailView(BaseScrobbleImportDetailView):
|
||||||
|
model = KoReaderImport
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleTSVImportDetailView(BaseScrobbleImportDetailView):
|
||||||
|
model = AudioScrobblerTSVImport
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleLastFMImportDetailView(BaseScrobbleImportDetailView):
|
||||||
|
model = LastFmImport
|
||||||
|
|
||||||
|
|
||||||
class ManualScrobbleView(FormView):
|
class ManualScrobbleView(FormView):
|
||||||
form_class = ScrobbleForm
|
form_class = ScrobbleForm
|
||||||
template_name = 'scrobbles/manual_form.html'
|
template_name = 'scrobbles/manual_form.html'
|
||||||
@ -215,19 +252,10 @@ def lastfm_import(request):
|
|||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@api_view(['GET'])
|
|
||||||
def scrobble_endpoint(request):
|
|
||||||
"""List all Scrobbles, or create a new Scrobble"""
|
|
||||||
scrobble = Scrobble.objects.all()
|
|
||||||
serializer = ScrobbleSerializer(scrobble, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
@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 (
|
||||||
@ -259,7 +287,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:
|
||||||
@ -293,7 +321,9 @@ def import_audioscrobbler_file(request):
|
|||||||
scrobbles_created = []
|
scrobbles_created = []
|
||||||
# tsv_file = request.FILES[0]
|
# tsv_file = request.FILES[0]
|
||||||
|
|
||||||
file_serializer = AudioScrobblerTSVImportSerializer(data=request.data)
|
file_serializer = serializers.AudioScrobblerTSVImportSerializer(
|
||||||
|
data=request.data
|
||||||
|
)
|
||||||
if file_serializer.is_valid():
|
if file_serializer.is_valid():
|
||||||
import_file = file_serializer.save()
|
import_file = file_serializer.save()
|
||||||
return Response(
|
return Response(
|
||||||
@ -305,39 +335,48 @@ def import_audioscrobbler_file(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def scrobble_finish(request, uuid):
|
def scrobble_finish(request, uuid):
|
||||||
user = request.user
|
user = request.user
|
||||||
|
success_url = reverse_lazy('vrobbler-home')
|
||||||
|
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return Response({}, status=status.HTTP_403_FORBIDDEN)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
||||||
if not scrobble:
|
if scrobble:
|
||||||
return Response({}, status=status.HTTP_404_NOT_FOUND)
|
scrobble.stop(force_finish=True)
|
||||||
scrobble.stop(force_finish=True)
|
messages.add_message(
|
||||||
return Response(
|
request,
|
||||||
{'id': scrobble.id, 'status': scrobble.status},
|
messages.SUCCESS,
|
||||||
status=status.HTTP_200_OK,
|
f"Scrobble of {scrobble.media_obj} finished.",
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
messages.add_message(request, messages.ERROR, "Scrobble not found.")
|
||||||
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def scrobble_cancel(request, uuid):
|
def scrobble_cancel(request, uuid):
|
||||||
user = request.user
|
user = request.user
|
||||||
|
success_url = reverse_lazy('vrobbler-home')
|
||||||
|
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return Response({}, status=status.HTTP_403_FORBIDDEN)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
||||||
if not scrobble:
|
if scrobble:
|
||||||
return Response({}, status=status.HTTP_404_NOT_FOUND)
|
scrobble.cancel()
|
||||||
scrobble.cancel()
|
messages.add_message(
|
||||||
return Response(
|
request,
|
||||||
{'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK
|
messages.SUCCESS,
|
||||||
)
|
f"Scrobble of {scrobble.media_obj} cancelled.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.add_message(request, messages.ERROR, "Scrobble not found.")
|
||||||
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
|
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@ -358,3 +397,149 @@ def export(request):
|
|||||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class ChartRecordView(TemplateView):
|
||||||
|
template_name = 'scrobbles/chart_index.html'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_media_filter(media_type: str = "") -> Q:
|
||||||
|
filters = {
|
||||||
|
"Track": Q(track__isnull=False),
|
||||||
|
"Artist": Q(artist__isnull=False),
|
||||||
|
"Series": Q(series__isnull=False),
|
||||||
|
"Video": Q(video__isnull=False),
|
||||||
|
"": Q(),
|
||||||
|
}
|
||||||
|
return filters[media_type]
|
||||||
|
|
||||||
|
def get_chart_records(self, media_type: str = "", **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 = ""
|
||||||
|
) -> QuerySet:
|
||||||
|
now = timezone.now()
|
||||||
|
params = {}
|
||||||
|
params['media_type'] = media
|
||||||
|
if period == "today":
|
||||||
|
params['day'] = now.day
|
||||||
|
params['month'] = now.month
|
||||||
|
params['year'] = now.year
|
||||||
|
if period == "week":
|
||||||
|
params['week'] = now.ioscalendar()[1]
|
||||||
|
params['year'] = now.year
|
||||||
|
if period == "month":
|
||||||
|
params['month'] = now.month
|
||||||
|
params['year'] = now.year
|
||||||
|
if period == "year":
|
||||||
|
params['year'] = now.year
|
||||||
|
return self.get_chart_records(**params)[: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", "Track")
|
||||||
|
user = self.request.user
|
||||||
|
params = {}
|
||||||
|
context_data["artist_charts"] = {}
|
||||||
|
|
||||||
|
if not date:
|
||||||
|
artist_params = {'user': user, 'media_type': 'Artist'}
|
||||||
|
context_data['current_artist_charts'] = {
|
||||||
|
"today": live_charts(**artist_params, chart_period="today"),
|
||||||
|
"week": live_charts(**artist_params, chart_period="week"),
|
||||||
|
"month": live_charts(**artist_params, chart_period="month"),
|
||||||
|
"all": live_charts(**artist_params),
|
||||||
|
}
|
||||||
|
|
||||||
|
track_params = {'user': user, 'media_type': 'Track'}
|
||||||
|
context_data['current_track_charts'] = {
|
||||||
|
"today": live_charts(**track_params, chart_period="today"),
|
||||||
|
"week": live_charts(**track_params, chart_period="week"),
|
||||||
|
"month": live_charts(**track_params, chart_period="month"),
|
||||||
|
"all": live_charts(**track_params),
|
||||||
|
}
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
# Date provided, lookup past charts, returning nothing if it's now or in the future.
|
||||||
|
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("Track")
|
||||||
|
track_charts = ChartRecord.objects.filter(
|
||||||
|
media_filter, user=self.request.user, **params
|
||||||
|
).order_by("rank")
|
||||||
|
media_filter = self.get_media_filter("Artist")
|
||||||
|
artist_charts = ChartRecord.objects.filter(
|
||||||
|
media_filter, user=self.request.user, **params
|
||||||
|
).order_by("rank")
|
||||||
|
|
||||||
|
if track_charts.count() == 0 and not in_progress:
|
||||||
|
ChartRecord.build(
|
||||||
|
user=self.request.user, model_str="Track", **params
|
||||||
|
)
|
||||||
|
media_filter = self.get_media_filter("Track")
|
||||||
|
track_charts = ChartRecord.objects.filter(
|
||||||
|
media_filter, user=self.request.user, **params
|
||||||
|
).order_by("rank")
|
||||||
|
if artist_charts.count() == 0 and not in_progress:
|
||||||
|
ChartRecord.build(
|
||||||
|
user=self.request.user, model_str="Artist", **params
|
||||||
|
)
|
||||||
|
media_filter = self.get_media_filter("Artist")
|
||||||
|
artist_charts = ChartRecord.objects.filter(
|
||||||
|
media_filter, user=self.request.user, **params
|
||||||
|
).order_by("rank")
|
||||||
|
|
||||||
|
context_data['media_type'] = media_type
|
||||||
|
context_data['track_charts'] = track_charts
|
||||||
|
context_data['artist_charts'] = artist_charts
|
||||||
|
context_data['name'] = " ".join(["Top", media_type, "for", name])
|
||||||
|
context_data['in_progress'] = in_progress
|
||||||
|
return context_data
|
||||||
|
|||||||
0
vrobbler/apps/sports/api/__init__.py
Normal file
0
vrobbler/apps/sports/api/__init__.py
Normal file
52
vrobbler/apps/sports/api/serializers.py
Normal file
52
vrobbler/apps/sports/api/serializers.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from sports.models import (
|
||||||
|
League,
|
||||||
|
SportEvent,
|
||||||
|
Round,
|
||||||
|
Player,
|
||||||
|
Team,
|
||||||
|
Season,
|
||||||
|
Sport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SportEventSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SportEvent
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class LeagueSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = League
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class RoundSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Round
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Player
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class TeamSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Team
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class SeasonSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Season
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class SportSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Sport
|
||||||
|
fields = "__all__"
|
||||||
61
vrobbler/apps/sports/api/views.py
Normal file
61
vrobbler/apps/sports/api/views.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
from sports.api.serializers import (
|
||||||
|
LeagueSerializer,
|
||||||
|
PlayerSerializer,
|
||||||
|
RoundSerializer,
|
||||||
|
SeasonSerializer,
|
||||||
|
SportEventSerializer,
|
||||||
|
SportSerializer,
|
||||||
|
TeamSerializer,
|
||||||
|
)
|
||||||
|
from sports.models import (
|
||||||
|
League,
|
||||||
|
Player,
|
||||||
|
Round,
|
||||||
|
Season,
|
||||||
|
Sport,
|
||||||
|
SportEvent,
|
||||||
|
Team,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SportEventViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = SportEvent.objects.all().order_by('-created')
|
||||||
|
serializer_class = SportEventSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class LeagueViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = League.objects.all().order_by('-created')
|
||||||
|
serializer_class = LeagueSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class RoundViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Round.objects.all().order_by('-created')
|
||||||
|
serializer_class = RoundSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class SportViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Sport.objects.all().order_by('-created')
|
||||||
|
serializer_class = SportSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Player.objects.all().order_by('-created')
|
||||||
|
serializer_class = PlayerSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class TeamViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Team.objects.all().order_by('-created')
|
||||||
|
serializer_class = TeamSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class SeasonViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Season.objects.all().order_by('-created')
|
||||||
|
serializer_class = SeasonSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
@ -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,
|
||||||
|
|||||||
0
vrobbler/apps/videos/api/__init__.py
Normal file
0
vrobbler/apps/videos/api/__init__.py
Normal file
14
vrobbler/apps/videos/api/serializers.py
Normal file
14
vrobbler/apps/videos/api/serializers.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from videos.models import Series, Video
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Series
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class VideoSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Video
|
||||||
|
fields = "__all__"
|
||||||
19
vrobbler/apps/videos/api/views.py
Normal file
19
vrobbler/apps/videos/api/views.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from videos.api.serializers import (
|
||||||
|
SeriesSerializer,
|
||||||
|
VideoSerializer,
|
||||||
|
)
|
||||||
|
from videos.models import Series, Video
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Series.objects.all().order_by('-created')
|
||||||
|
serializer_class = SeriesSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class VideoViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Video.objects.all().order_by('-created')
|
||||||
|
serializer_class = VideoSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
@ -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)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
import dj_database_url
|
import dj_database_url
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@ -50,13 +51,9 @@ DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
|
|||||||
# Used to dump data coming from srobbling sources, helpful for building new inputs
|
# Used to dump data coming from srobbling sources, helpful for building new inputs
|
||||||
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
|
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
|
||||||
|
|
||||||
|
|
||||||
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
|
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
|
||||||
THESPORTSDB_BASE_URL = os.getenv(
|
THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
|
||||||
"VROBBLER_THESPORTSDB_BASE_URL", "https://www.thesportsdb.com/api/v1/json/"
|
|
||||||
)
|
|
||||||
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
||||||
|
|
||||||
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
|
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
|
||||||
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
|
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
|
||||||
|
|
||||||
@ -71,6 +68,10 @@ CSRF_TRUSTED_ORIGINS = [
|
|||||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||||
|
|
||||||
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
|
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
|
||||||
|
if REDIS_URL:
|
||||||
|
print(f"Sending tasks to redis@{REDIS_URL.split('@')[-1]}")
|
||||||
|
else:
|
||||||
|
print("Eagerly running all tasks")
|
||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False)
|
CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False)
|
||||||
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
|
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
|
||||||
@ -135,11 +136,14 @@ TEMPLATES = [
|
|||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
"videos.context_processors.video_lists",
|
"videos.context_processors.video_lists",
|
||||||
"music.context_processors.music_lists",
|
"music.context_processors.music_lists",
|
||||||
|
"scrobbles.context_processors.now_playing",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
|
||||||
|
|
||||||
WSGI_APPLICATION = "vrobbler.wsgi.application"
|
WSGI_APPLICATION = "vrobbler.wsgi.application"
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
@ -148,10 +152,18 @@ DATABASES = {
|
|||||||
conn_max_age=600,
|
conn_max_age=600,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if TESTING:
|
if TESTING:
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": dj_database_url.config(default="sqlite:///testdb.sqlite3")
|
"default": dj_database_url.config(default="sqlite:///testdb.sqlite3")
|
||||||
}
|
}
|
||||||
|
db_str = ""
|
||||||
|
if 'sqlite' in DATABASES['default']['ENGINE']:
|
||||||
|
db_str = f"Connected to sqlite@{DATABASES['default']['NAME']}"
|
||||||
|
if 'postgresql' in DATABASES['default']['ENGINE']:
|
||||||
|
db_str = f"Connected to postgres@{DATABASES['default']['HOST']}/{DATABASES['default']['NAME']}"
|
||||||
|
if db_str:
|
||||||
|
print(db_str)
|
||||||
|
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
@ -171,22 +183,19 @@ 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.BasicAuthentication',
|
|
||||||
'rest_framework.authentication.TokenAuthentication',
|
'rest_framework.authentication.TokenAuthentication',
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
],
|
],
|
||||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'vrobbler.negotiation.IgnoreClientContentNegotiation',
|
||||||
"DEFAULT_FILTER_BACKENDS": [
|
"DEFAULT_FILTER_BACKENDS": [
|
||||||
"django_filters.rest_framework.DjangoFilterBackend"
|
"django_filters.rest_framework.DjangoFilterBackend"
|
||||||
],
|
],
|
||||||
'DEFAULT_PARSER_CLASSES': [
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||||
'rest_framework.parsers.JSONParser',
|
"PAGE_SIZE": 200,
|
||||||
],
|
|
||||||
'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'vrobbler.negotiation.IgnoreClientContentNegotiation',
|
|
||||||
"PAGE_SIZE": 100,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
|||||||
@ -177,7 +177,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<form id="scrobble-form" action="{% url 'imdb-manual-scrobble' %}" method="post">
|
<form id="scrobble-form" action="{% url 'scrobbles:imdb-manual-scrobble' %}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ imdb_form }}
|
{{ imdb_form }}
|
||||||
</form>
|
</form>
|
||||||
@ -197,30 +197,13 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
|
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
|
||||||
<div class="position-sticky pt-3">
|
<div class="position-sticky pt-3">
|
||||||
{% if now_playing_list and user.is_authenticated %}
|
{% if messages %}
|
||||||
<ul style="padding-right:10px;">
|
<ul style="padding-right:10px;">
|
||||||
<b>Now playing</b>
|
{% for message in messages %}
|
||||||
{% for scrobble in now_playing_list %}
|
<li {% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
|
||||||
<div>
|
{% endfor %}
|
||||||
{{scrobble.media_obj.title}}<br/>
|
|
||||||
{% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %}
|
|
||||||
{% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %}
|
|
||||||
{% if scrobble.video.tv_series %}<em>{{scrobble.video.tv_series }}</em><br/>{% endif %}
|
|
||||||
{% if scrobble.sport_event %}<em>{{scrobble.sport_event.round.season.league}}</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>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="/">
|
<a class="nav-link active" aria-current="page" href="/">
|
||||||
@ -228,6 +211,12 @@
|
|||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" aria-current="page" href="/charts/">
|
||||||
|
<span data-feather="bar-chart"></span>
|
||||||
|
Charts
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" aria-current="page" href="/tracks/">
|
<a class="nav-link" aria-current="page" href="/tracks/">
|
||||||
<span data-feather="music"></span>
|
<span data-feather="music"></span>
|
||||||
@ -249,7 +238,7 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/series/">
|
<a class="nav-link" href="/series/">
|
||||||
<span data-feather="tv"></span>
|
<span data-feather="tv"></span>
|
||||||
Series
|
TV Shows
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
@ -263,6 +252,40 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% block extra_nav %}
|
{% block extra_nav %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
<hr/>
|
||||||
|
|
||||||
|
{% if now_playing_list and user.is_authenticated %}
|
||||||
|
<ul style="padding-right:10px;">
|
||||||
|
<b>Now playing</b>
|
||||||
|
{% for scrobble in now_playing_list %}
|
||||||
|
<div>
|
||||||
|
{% if scrobble.media_obj.album.cover_image %}
|
||||||
|
<td><img src="{{scrobble.track.album.cover_image.url}}" width=120 height=120
|
||||||
|
style="border:1px solid black; " /></td><br/>
|
||||||
|
{% endif %}
|
||||||
|
{{scrobble.media_obj.title}}<br/>
|
||||||
|
{% if scrobble.media_obj.subtitle %}<em>{{scrobble.media_obj.subtitle}}</em><br/>{% endif %}
|
||||||
|
<small>{{scrobble.timestamp|naturaltime}}<br/>
|
||||||
|
from {{scrobble.source}}</small>
|
||||||
|
<div class="progress-bar" style="margin-right:5px;">
|
||||||
|
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
||||||
|
</div>
|
||||||
|
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
|
||||||
|
<a href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<hr/>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if active_imports %}
|
||||||
|
{% for import in active_imports %}
|
||||||
|
<ul style="padding-right:10px;">
|
||||||
|
<li>Import in progress ({{import.processing_started|naturaltime}})</li>
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@ -271,6 +294,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
|
||||||
<script><!-- comment ------------------------------------------------->
|
<script><!-- comment ------------------------------------------------->
|
||||||
/* globals Chart:false, feather:false */
|
/* globals Chart:false, feather:false */
|
||||||
|
|||||||
@ -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 %}
|
||||||
78
vrobbler/templates/music/album_detail.html
Normal file
78
vrobbler/templates/music/album_detail.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load mathfilters %}
|
||||||
|
|
||||||
|
{% block title %}{{object.name}}{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
{% if object.cover_image %}
|
||||||
|
<p style="float:left; width:302px; padding:0; border: 1px solid #ccc">
|
||||||
|
<img src="{{object.cover_image.url}}" width=300 height=300 />
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<p>{{object.scrobbles.count}} scrobbles</p>
|
||||||
|
{% if charts %}
|
||||||
|
<p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Top tracks</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Rank</th>
|
||||||
|
<th scope="col">Track</th>
|
||||||
|
<th scope="col">Artist</th>
|
||||||
|
<th scope="col">Count</th>
|
||||||
|
<th scope="col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for track in object.tracks %}
|
||||||
|
<tr>
|
||||||
|
<td>{{rank}}#1</td>
|
||||||
|
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td>
|
||||||
|
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
|
||||||
|
<td>{{track.scrobble_count}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress-bar" style="margin-right:5px;">
|
||||||
|
<span class="progress-bar-fill" style="width: {{track.scrobble_count|mul:10}}%;"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<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">Track</th>
|
||||||
|
<th scope="col">Artist</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in object.scrobbles %}
|
||||||
|
<tr>
|
||||||
|
<td>{{scrobble.timestamp}}</td>
|
||||||
|
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
|
||||||
|
<td><a href="{{scrobble.track.artist.get_absolute_url}}">{{scrobble.track.artist.name}}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -1,19 +1,29 @@
|
|||||||
{% extends "base_detail.html" %}
|
{% extends "base_list.html" %}
|
||||||
{% load mathfilters %}
|
{% load mathfilters %}
|
||||||
|
|
||||||
{% block title %}{{object.name}}{% endblock %}
|
{% block title %}{{object.name}}{% endblock %}
|
||||||
|
|
||||||
{% block details %}
|
{% block lists %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for album in artist.album_set.all %}
|
|
||||||
{% if album.cover_image %}
|
{% if object.thumbnail %}
|
||||||
<p style="width:150px; float:left;"><img src="{{album.cover_image.url}}" width=150 height=150 /></p>
|
<p style="float:left; width:302px; padding:0; border: 1px solid #ccc">
|
||||||
|
<img src="{{artist.thumbnail.url}}" width=300 height=300 />
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
{% if object.album_set.first.cover_image %}
|
||||||
|
<p style="float:left; width:302px; padding:0; border: 1px solid #ccc">
|
||||||
|
<img src="{{object.album_set.first.cover_image.url}}" width=300 height=300 />
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p>{{artist.scrobbles.count}} scrobbles</p>
|
<p>{{artist.scrobbles.count}} scrobbles</p>
|
||||||
|
{% if charts %}
|
||||||
|
<p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p>
|
||||||
|
{% endif %}
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<h3>Top tracks</h3>
|
<h3>Top tracks</h3>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -22,6 +32,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Rank</th>
|
<th scope="col">Rank</th>
|
||||||
<th scope="col">Track</th>
|
<th scope="col">Track</th>
|
||||||
|
<th scope="col">Album</th>
|
||||||
<th scope="col">Count</th>
|
<th scope="col">Count</th>
|
||||||
<th scope="col"></th>
|
<th scope="col"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -30,7 +41,8 @@
|
|||||||
{% for track in object.tracks %}
|
{% for track in object.tracks %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{rank}}#1</td>
|
<td>{{rank}}#1</td>
|
||||||
<td>{{track.title}}</td>
|
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td>
|
||||||
|
<td><a href="{{track.album.get_absolute_url}}">{{track.album}}</a></td>
|
||||||
<td>{{track.scrobble_count}}</td>
|
<td>{{track.scrobble_count}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="progress-bar" style="margin-right:5px;">
|
<div class="progress-bar" style="margin-right:5px;">
|
||||||
@ -60,8 +72,8 @@
|
|||||||
{% for scrobble in object.scrobbles %}
|
{% for scrobble in object.scrobbles %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{scrobble.timestamp}}</td>
|
<td>{{scrobble.timestamp}}</td>
|
||||||
<td>{{scrobble.track.title}}</td>
|
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
|
||||||
<td>{{scrobble.track.album.name}}</td>
|
<td><a href="{{scrobble.track.album.get_absolute_url}}">{{scrobble.track.album.name}}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -1,13 +1,43 @@
|
|||||||
{% extends "base_detail.html" %}
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
{% block title %}{{object.title}}{% endblock %}
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
{% block details %}
|
{% block lists %}
|
||||||
<h2>Last scrobbles</h2>
|
<div class="row">
|
||||||
{% for scrobble in object.scrobble_set.all %}
|
{% if track.album.cover_image %}
|
||||||
<ul>
|
<p style="width:150px; float:left;"><img src="{{track.album.cover_image.url}}" width=150 height=150 /></p>
|
||||||
<li>{{scrobble.timestamp|date:"d M Y h:m"}} - <img src="{{object.album.cover_image.url}}" width=25 height=25 /> - {{object}}</li>
|
{% endif %}
|
||||||
</ul>
|
</div>
|
||||||
{% endfor %}
|
<div class="row">
|
||||||
|
<p>{{object.scrobble_set.count}} scrobbles</p>
|
||||||
|
{% if charts %}
|
||||||
|
<p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p>
|
||||||
|
{% endif %}
|
||||||
|
<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">Track</th>
|
||||||
|
<th scope="col">Album</th>
|
||||||
|
<th scope="col">Artist</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in object.scrobble_set.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{scrobble.timestamp}}</td>
|
||||||
|
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
|
||||||
|
<td><a href="{{scrobble.track.album.get_absolute_url}}">{{scrobble.track.album}}</a></td>
|
||||||
|
<td><a href="{{scrobble.track.artist.get_absolute_url}}">{{scrobble.track.artist}}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
150
vrobbler/templates/scrobbles/chart_index.html
Normal file
150
vrobbler/templates/scrobbles/chart_index.html
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{name}}{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
{% if artist_charts %}
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="tab-content" id="artistTabContent">
|
||||||
|
<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 chart in artist_charts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{chart.rank}}</td>
|
||||||
|
<td><a href="{{chart.media_obj.get_absolute_url}}">{{chart.media_obj}}</a></td>
|
||||||
|
<td>{{chart.count}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if track_charts %}
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="tab-content" id="artistTabContent">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Rank</th>
|
||||||
|
<th scope="col">Track</th>
|
||||||
|
<th scope="col">Scrobbles</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for chart in track_charts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{chart.rank}}</td>
|
||||||
|
<td><a href="{{chart.media_obj.get_absolute_url}}">{{chart.media_obj.title}}</a></td>
|
||||||
|
<td>{{chart.count}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_artist_charts %}
|
||||||
|
<div class="col-md">
|
||||||
|
<h2>Top Artists</h2>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs" id="artistTab" role="tablist">
|
||||||
|
{% for chart_name in current_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 current_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">Artist</th>
|
||||||
|
<th scope="col">Scrobbles</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for artist in artists %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
|
||||||
|
<td>{{artist.num_scrobbles}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_track_charts %}
|
||||||
|
<div class="col-md">
|
||||||
|
<h2>Top Tracks</h2>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs" id="artistTab" role="tablist">
|
||||||
|
{% for chart_name in current_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 current_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">Track</th>
|
||||||
|
<th scope="col">Artist</th>
|
||||||
|
<th scope="col">Scrobbles</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for track in tracks %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td>
|
||||||
|
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
|
||||||
|
<td>{{track.num_scrobbles}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</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 %}
|
||||||
@ -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>
|
||||||
@ -46,260 +46,130 @@
|
|||||||
This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
|
This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
<li class="nav-item" role="presentation">
|
||||||
<li class="nav-item" role="presentation">
|
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened"
|
||||||
<button class="nav-link active" id="home-tab" data-bs-toggle="tab"
|
type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button>
|
||||||
data-bs-target="#artists-week" type="button" role="tab" aria-controls="home"
|
</li>
|
||||||
aria-selected="true">Weekly Artists</button>
|
<li class="nav-item" role="presentation">
|
||||||
</li>
|
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched"
|
||||||
<li class="nav-item" role="presentation">
|
type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
|
||||||
<button class="nav-link" id="artist-month-tab" data-bs-toggle="tab"
|
</li>
|
||||||
data-bs-target="#artists-month" type="button" role="tab" aria-controls="home"
|
<li class="nav-item" role="presentation">
|
||||||
aria-selected="true">Monthly Artists</button>
|
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted"
|
||||||
</li>
|
type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button>
|
||||||
<li class="nav-item" role="presentation">
|
</li>
|
||||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-week"
|
<li class="nav-item" role="presentation">
|
||||||
type="button" role="tab" aria-controls="profile" aria-selected="false">Weekly
|
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
|
||||||
Tracks</button>
|
type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
</ul>
|
||||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-month"
|
|
||||||
type="button" role="tab" aria-controls="profile" aria-selected="false">Monthly
|
|
||||||
Tracks</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="tab-content" id="myTabContent">
|
<div class="tab-content" id="myTabContent2">
|
||||||
<div class="tab-pane fade show active" id="artists-week" role="tabpanel"
|
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel"
|
||||||
aria-labelledby="artists-week-tab">
|
aria-labelledby="latest-listened-tab">
|
||||||
<h2>Top artists this week</h2>
|
<h2>Latest listened</h2>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">#</th>
|
<th scope="col">Time</th>
|
||||||
<th scope="col">Artist</th>
|
<th scope="col">Album</th>
|
||||||
</tr>
|
<th scope="col">Track</th>
|
||||||
</thead>
|
<th scope="col">Artist</th>
|
||||||
<tbody>
|
</tr>
|
||||||
{% for artist in top_weekly_artists %}
|
</thead>
|
||||||
<tr>
|
<tbody>
|
||||||
<td>{{artist.num_scrobbles}}</td>
|
{% for scrobble in object_list %}
|
||||||
<td>{{artist.name}}</td>
|
<tr>
|
||||||
</tr>
|
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||||
{% endfor %}
|
{% if scrobble.track.album.cover_image %}
|
||||||
</tbody>
|
<td><a href="{{scrobble.track.album.get_absolute_url}}"><img src="{{scrobble.track.album.cover_image.url}}" width=25 height=25 style="border:1px solid black;" /></aa></td>
|
||||||
</table>
|
{% else %}
|
||||||
</div>
|
<td><a href="{{scrobble.track.album.get_absolute_url}}">{{scrobble.track.album.name}}</a></td>
|
||||||
|
{% endif %}
|
||||||
|
<td><a href="{{scrobble.track.get_absolute_url }}">{{scrobble.track.title}}</a></td>
|
||||||
|
<td><a href="{{scrobble.track.artist.get_absolute_url }}">{{scrobble.track.artist.name}}</aa></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane fade show" id="tracks-week" role="tabpanel" aria-labelledby="tracks-week-tab">
|
|
||||||
<h2>Top tracks this week</h2>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">#</th>
|
|
||||||
<th scope="col">Track</th>
|
|
||||||
<th scope="col">Artist</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for track in top_weekly_tracks %}
|
|
||||||
<tr>
|
|
||||||
<td>{{track.num_scrobbles}}</td>
|
|
||||||
<td>{{track.title}}</td>
|
|
||||||
<td>{{track.artist.name}}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="tab-pane fade show" id="tracks-month" role="tabpanel"
|
|
||||||
aria-labelledby="tracks-month-tab">
|
|
||||||
<h2>Top tracks this month</h2>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">#</th>
|
|
||||||
<th scope="col">Track</th>
|
|
||||||
<th scope="col">Artist</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for track in top_monthly_tracks %}
|
|
||||||
<tr>
|
|
||||||
<td>{{track.num_scrobbles}}</td>
|
|
||||||
<td>{{track.title}}</td>
|
|
||||||
<td>{{track.artist.name}}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-pane fade show " id="artists-month" role="tabpanel"
|
|
||||||
aria-labelledby="artists-month-tab">
|
|
||||||
<h2>Top artists this month</h2>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">#</th>
|
|
||||||
<th scope="col">Artist</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for artist in top_monthly_artists %}
|
|
||||||
<tr>
|
|
||||||
<td>{{artist.num_scrobbles}}</td>
|
|
||||||
<td>{{artist.name}}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<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" id="latest-watched" role="tabpanel"
|
||||||
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel"
|
aria-labelledby="latest-watched-tab">
|
||||||
aria-labelledby="latest-listened-tab">
|
<h2>Latest watched</h2>
|
||||||
<h2>Latest listened</h2>
|
<div class="table-responsive">
|
||||||
<div class="table-responsive">
|
<table class="table table-striped table-sm">
|
||||||
<table class="table table-striped table-sm">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th scope="col">Time</th>
|
||||||
<th scope="col">Time</th>
|
<th scope="col">Title</th>
|
||||||
<th scope="col">Album</th>
|
<th scope="col">Series</th>
|
||||||
<th scope="col">Track</th>
|
</tr>
|
||||||
<th scope="col">Artist</th>
|
</thead>
|
||||||
</tr>
|
<tbody>
|
||||||
</thead>
|
{% for scrobble in video_scrobble_list %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for scrobble in object_list %}
|
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||||
<tr>
|
<td>{% if scrobble.video.tv_series%}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{%endif %} {{scrobble.video.title}}</td>
|
||||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}
|
||||||
{% if scrobble.track.album.cover_image %}
|
</td>
|
||||||
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50
|
</tr>
|
||||||
style="border:1px solid black;" /></td>
|
{% endfor %}
|
||||||
{% else %}
|
</tbody>
|
||||||
<td>{{scrobble.track.album.name}}</td>
|
</table>
|
||||||
{% endif %}
|
|
||||||
<td>{{scrobble.track.title}}</td>
|
|
||||||
<td>{{scrobble.track.artist.name}}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane fade show" id="latest-watched" role="tabpanel"
|
<div class="tab-pane fade show" id="latest-sports" role="tabpanel" aria-labelledby="latest-sports-tab">
|
||||||
aria-labelledby="latest-watched-tab">
|
<h2>Latest Sports</h2>
|
||||||
<h2>Latest watched</h2>
|
<div class="table-responsive">
|
||||||
<div class="table-responsive">
|
<table class="table table-striped table-sm">
|
||||||
<table class="table table-striped table-sm">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th scope="col">Date</th>
|
||||||
<th scope="col">Time</th>
|
<th scope="col">Title</th>
|
||||||
<th scope="col">Title</th>
|
<th scope="col">League</th>
|
||||||
<th scope="col">Series</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{% for scrobble in sport_scrobble_list %}
|
||||||
{% for scrobble in video_scrobble_list %}
|
<tr>
|
||||||
<tr>
|
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
<td>{{scrobble.sport_event.title}}</td>
|
||||||
<td>{% if scrobble.video.tv_series %}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{% endif %} {{scrobble.video.title}}</td>
|
<td>{{scrobble.sport_event.league.abbreviation}}</td>
|
||||||
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}
|
</tr>
|
||||||
</td>
|
{% endfor %}
|
||||||
</tr>
|
</tbody>
|
||||||
{% endfor %}
|
</table>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane fade show" id="latest-sports" role="tabpanel"
|
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel"
|
||||||
aria-labelledby="latest-sports-tab">
|
aria-labelledby="latest-podcasted-tab">
|
||||||
<h2>Latest Sports</h2>
|
<h2>Latest Podcasted</h2>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Date</th>
|
<th scope="col">Date</th>
|
||||||
<th scope="col">Title</th>
|
<th scope="col">Title</th>
|
||||||
<th scope="col">League</th>
|
<th scope="col">Podcast</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for scrobble in sport_scrobble_list %}
|
{% for scrobble in podcast_scrobble_list %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||||
<td>{{scrobble.sport_event.title}}</td>
|
<td>{{scrobble.podcast_episode.title}}</td>
|
||||||
<td>{{scrobble.sport_event.league.abbreviation}}</td>
|
<td>{{scrobble.podcast_episode.podcast}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel"
|
|
||||||
aria-labelledby="latest-podcasted-tab">
|
|
||||||
<h2>Latest Podcasted</h2>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Date</th>
|
|
||||||
<th scope="col">Title</th>
|
|
||||||
<th scope="col">Podcast</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for scrobble in podcast_scrobble_list %}
|
|
||||||
<tr>
|
|
||||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
|
||||||
<td>{{scrobble.podcast_episode.title}}</td>
|
|
||||||
<td>{{scrobble.podcast_episode.podcast}}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -316,7 +186,7 @@
|
|||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form action="{% url 'audioscrobbler-file-upload' %}" method="post" enctype="multipart/form-data">
|
<form action="{% url 'scrobbles:audioscrobbler-file-upload' %}" method="post" enctype="multipart/form-data">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -328,7 +198,7 @@
|
|||||||
<button type="submit" class="btn btn-primary">Import</button>
|
<button type="submit" class="btn btn-primary">Import</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form action="{% url 'koreader-file-upload' %}" method="post" enctype="multipart/form-data">
|
<form action="{% url 'scrobbles:koreader-file-upload' %}" method="post" enctype="multipart/form-data">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
{% 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-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">
|
<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">Manual scrobble</h1>
|
<h1 class="h2">Manual scrobble</h1>
|
||||||
<form action="{% url 'audioscrobbler-file-upload' %}" method="post">
|
<form action="{% url 'scrobbles:audioscrobbler-file-upload' %}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form }}
|
{{ form }}
|
||||||
<input type="submit" value="Submit">
|
<input type="submit" value="Submit">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -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 %}
|
|
||||||
@ -3,34 +3,64 @@ from django.conf import settings
|
|||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from scrobbles import urls as scrobble_urls
|
from rest_framework import routers
|
||||||
from music import urls as music_urls
|
|
||||||
from videos import urls as video_urls
|
from vrobbler.apps.books.api.views import AuthorViewSet, BookViewSet
|
||||||
|
from vrobbler.apps.music import urls as music_urls
|
||||||
|
from vrobbler.apps.music.api.views import (
|
||||||
|
AlbumViewSet,
|
||||||
|
ArtistViewSet,
|
||||||
|
TrackViewSet,
|
||||||
|
)
|
||||||
|
from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet
|
||||||
|
from vrobbler.apps.scrobbles import urls as scrobble_urls
|
||||||
|
from vrobbler.apps.scrobbles.api.views import (
|
||||||
|
AudioScrobblerTSVImportViewSet,
|
||||||
|
KoReaderImportViewSet,
|
||||||
|
LastFmImportViewSet,
|
||||||
|
ScrobbleViewSet,
|
||||||
|
)
|
||||||
|
from vrobbler.apps.sports.api.views import (
|
||||||
|
LeagueViewSet,
|
||||||
|
PlayerViewSet,
|
||||||
|
RoundViewSet,
|
||||||
|
SeasonViewSet,
|
||||||
|
SportEventViewSet,
|
||||||
|
SportViewSet,
|
||||||
|
TeamViewSet,
|
||||||
|
)
|
||||||
|
from vrobbler.apps.videos import urls as video_urls
|
||||||
|
from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet
|
||||||
|
|
||||||
|
router = routers.DefaultRouter()
|
||||||
|
router.register(r'scrobbles', ScrobbleViewSet)
|
||||||
|
router.register(r'lastfm-imports', LastFmImportViewSet)
|
||||||
|
router.register(r'tsv-imports', AudioScrobblerTSVImportViewSet)
|
||||||
|
router.register(r'koreader-imports', KoReaderImportViewSet)
|
||||||
|
router.register(r'artist', ArtistViewSet)
|
||||||
|
router.register(r'album', AlbumViewSet)
|
||||||
|
router.register(r'tracks', TrackViewSet)
|
||||||
|
router.register(r'series', SeriesViewSet)
|
||||||
|
router.register(r'videos', VideoViewSet)
|
||||||
|
router.register(r'authors', AuthorViewSet)
|
||||||
|
router.register(r'books', BookViewSet)
|
||||||
|
router.register(r'leagues', LeagueViewSet)
|
||||||
|
router.register(r'sports', SportViewSet)
|
||||||
|
router.register(r'seasons', SeasonViewSet)
|
||||||
|
router.register(r'players', PlayerViewSet)
|
||||||
|
router.register(r'sport-events', SportEventViewSet)
|
||||||
|
router.register(r'teams', TeamViewSet)
|
||||||
|
router.register(r'users', UserViewSet)
|
||||||
|
router.register(r'user_profiles', UserProfileViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('api/v1/', include(router.urls)),
|
||||||
|
path('api/v1/auth', include("rest_framework.urls")),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
# path("api-auth/", include("rest_framework.urls")),
|
|
||||||
# path("movies/", include(movies, namespace="movies")),
|
|
||||||
# path("shows/", include(shows, namespace="shows")),
|
|
||||||
path("api/v1/scrobbles/", include(scrobble_urls, namespace="scrobbles")),
|
|
||||||
path(
|
|
||||||
'manual/imdb/',
|
|
||||||
scrobbles_views.ManualScrobbleView.as_view(),
|
|
||||||
name='imdb-manual-scrobble',
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
'manual/audioscrobbler/',
|
|
||||||
scrobbles_views.AudioScrobblerImportCreateView.as_view(),
|
|
||||||
name='audioscrobbler-file-upload',
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
'manual/koreader/',
|
|
||||||
scrobbles_views.KoReaderImportCreateView.as_view(),
|
|
||||||
name='koreader-file-upload',
|
|
||||||
),
|
|
||||||
path("", include(music_urls, namespace="music")),
|
path("", include(music_urls, namespace="music")),
|
||||||
path("", include(video_urls, namespace="videos")),
|
path("", include(video_urls, namespace="videos")),
|
||||||
|
path("", include(scrobble_urls, namespace="scrobbles")),
|
||||||
path(
|
path(
|
||||||
"", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"
|
"", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user