major music updates, minor template fixes

This commit is contained in:
Alexander Karpov 2023-12-16 21:11:08 +03:00
parent fdfe839d19
commit 127b4b6e11
9 changed files with 297 additions and 32 deletions

View File

@ -1,9 +1,17 @@
from django.contrib import admin
from akarpov.music.models import Album, Author, Playlist, PlaylistSong, Song
from akarpov.music.models import (
Album,
Author,
Playlist,
PlaylistSong,
Song,
SongUserRating,
)
admin.site.register(Author)
admin.site.register(Album)
admin.site.register(Song)
admin.site.register(Playlist)
admin.site.register(PlaylistSong)
admin.site.register(SongUserRating)

View File

@ -32,7 +32,7 @@ class SongSerializer(serializers.ModelSerializer):
@extend_schema_field(serializers.BooleanField)
def get_liked(self, obj):
if "request" in self.context:
if "request" in self.context and self.context["request"]:
if self.context["request"].user.is_authenticated:
return SongUserRating.objects.filter(
song=obj, user=self.context["request"].user, like=True
@ -61,8 +61,8 @@ class Meta:
class ListSongSerializer(SetUserModelSerializer):
album = AlbumSerializer(read_only=True)
authors = AuthorSerializer(many=True, read_only=True)
album = serializers.SerializerMethodField(method_name="get_album")
authors = serializers.SerializerMethodField(method_name="get_authors")
liked = serializers.SerializerMethodField(method_name="get_liked")
@extend_schema_field(serializers.BooleanField)
@ -73,6 +73,20 @@ def get_liked(self, obj):
return obj.id in self.context["likes_ids"]
return None
@extend_schema_field(AlbumSerializer)
def get_album(self, obj):
if obj.album:
return AlbumSerializer(Album.objects.cache().get(id=obj.album_id)).data
return None
@extend_schema_field(AuthorSerializer(many=True))
def get_authors(self, obj):
if obj.authors:
return AuthorSerializer(
Author.objects.cache().filter(songs__id=obj.id), many=True
).data
return None
class Meta:
model = Song
fields = [

View File

@ -9,8 +9,11 @@
ListCreatePlaylistAPIView,
ListCreateSongAPIView,
ListDislikedSongsAPIView,
ListenSongAPIView,
ListLikedSongsAPIView,
ListPublicPlaylistAPIView,
ListSongPlaylistsAPIView,
ListUserListenedSongsAPIView,
RemoveSongFromPlaylistAPIView,
RetrieveUpdateDestroyAlbumAPIView,
RetrieveUpdateDestroyAuthorAPIView,
@ -42,10 +45,25 @@
RetrieveUpdateDestroySongAPIView.as_view(),
name="retrieve_update_delete_song",
),
path("song/like/", LikeSongAPIView.as_view()),
path("song/dislike/", DislikeSongAPIView.as_view()),
path("playlists/add/", AddSongToPlaylistAPIView.as_view()),
path("playlists/remove/", RemoveSongFromPlaylistAPIView.as_view()),
path(
"song/<str:slug>/playlists/",
ListSongPlaylistsAPIView.as_view(),
name="list_song_playlists",
),
path("song/listen/", ListenSongAPIView.as_view(), name="listen-song"),
path("song/listened/", ListUserListenedSongsAPIView.as_view(), name="listened"),
path("song/like/", LikeSongAPIView.as_view(), name="like-song"),
path("song/dislike/", DislikeSongAPIView.as_view(), name="dislike-song"),
path(
"playlists/add/",
AddSongToPlaylistAPIView.as_view(),
name="add-song-to-playlists",
),
path(
"playlists/remove/",
RemoveSongFromPlaylistAPIView.as_view(),
name="playlists-remove",
),
path("albums/", ListAlbumsAPIView.as_view(), name="list_albums"),
path(
"albums/<str:slug>",

View File

@ -1,4 +1,6 @@
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema
from rest_framework import generics, permissions
from rest_framework.response import Response
from akarpov.common.api.pagination import StandardResultsSetPagination
from akarpov.common.api.permissions import IsAdminOrReadOnly, IsCreatorOrReadOnly
@ -15,7 +17,15 @@
PlaylistSerializer,
SongSerializer,
)
from akarpov.music.models import Album, Author, Playlist, Song, SongUserRating
from akarpov.music.models import (
Album,
Author,
Playlist,
Song,
SongUserRating,
UserListenHistory,
)
from akarpov.music.tasks import listen_to_song
class LikedSongsContextMixin(generics.GenericAPIView):
@ -73,19 +83,76 @@ class ListCreateSongAPIView(LikedSongsContextMixin, generics.ListCreateAPIView):
pagination_class = StandardResultsSetPagination
def get_queryset(self):
qs = Song.objects.cache()
if "sort" in self.request.query_params:
sorts = self.request.query_params["sort"].split(",")
for sort in sorts:
pref = "-"
if sort.startswith("-"):
pref = ""
if sort == "likes":
qs = qs.order_by(pref + "likes")
elif sort == "length":
qs = qs.order_by(pref + "length")
elif sort == "played":
qs = qs.order_by(pref + "played")
elif sort == "uploaded":
qs = qs.order_by(pref + "created")
if self.request.user.is_authenticated:
return (
Song.objects.all()
.exclude(
id__in=SongUserRating.objects.filter(
user=self.request.user,
like=False,
).values_list("song_id", flat=True)
)
.prefetch_related("authors")
.select_related("album")
return qs.exclude(
id__in=SongUserRating.objects.filter(
user=self.request.user,
like=False,
).values_list("song_id", flat=True)
)
return Song.objects.all().prefetch_related("authors").select_related("album")
return qs
@extend_schema(
parameters=[
OpenApiParameter(
name="sort",
description="Sorting algorithm",
required=False,
type=str,
examples=[
OpenApiExample(
"Default",
description="by date added",
value=None,
),
OpenApiExample(
"played",
description="by total times played",
value="played",
),
OpenApiExample(
"likes",
description="by total likes",
value="likes",
),
OpenApiExample(
"likes reversed",
description="by total likes",
value="-likes",
),
OpenApiExample(
"length",
description="by track length",
value="length",
),
OpenApiExample(
"uploaded",
description="by date uploaded",
value="uploaded",
),
],
),
]
)
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
class RetrieveUpdateDestroySongAPIView(generics.RetrieveUpdateDestroyAPIView):
@ -142,15 +209,10 @@ def get_serializer_context(self):
return context
def get_queryset(self):
return (
Song.objects.cache()
.filter(
id__in=SongUserRating.objects.cache()
.filter(user=self.request.user, like=True)
.values_list("song_id", flat=False)
)
.prefetch_related("authors")
.select_related("album")
return Song.objects.cache().filter(
id__in=SongUserRating.objects.cache()
.filter(user=self.request.user, like=True)
.values_list("song_id", flat=False)
)
@ -240,3 +302,45 @@ class RetrieveUpdateDestroyAuthorAPIView(
lookup_url_kwarg = "slug"
permission_classes = [IsAdminOrReadOnly]
serializer_class = FullAuthorSerializer
class ListenSongAPIView(generics.GenericAPIView):
serializer_class = LikeDislikeSongSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
return Song.objects.cache()
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=False)
data = serializer.validated_data
try:
song = Song.objects.cache().get(slug=data["song"])
except Song.DoesNotExist:
return Response(status=404)
if self.request.user.is_authenticated:
listen_to_song.apply_async(
kwargs={"song_id": song.id, "user_id": self.request.user.id},
countdown=2,
)
else:
listen_to_song.apply_async(
kwargs={"song_id": song.id},
countdown=2,
)
return Response(status=201)
class ListUserListenedSongsAPIView(generics.ListAPIView):
serializer_class = ListSongSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Song.objects.cache().filter(
id__in=UserListenHistory.objects.cache()
.filter(user=self.request.user)
.values_list("song_id", flat=True)
)

View File

@ -0,0 +1,54 @@
# Generated by Django 4.2.7 on 2023-12-16 17:53
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("music", "0010_song_likes_songuserrating"),
]
operations = [
migrations.AlterField(
model_name="playlist",
name="private",
field=models.BooleanField(default=True),
),
migrations.CreateModel(
name="UserListenHistory",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"song",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="listeners",
to="music.song",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="songs_listened",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created"],
},
),
]

View File

@ -147,3 +147,14 @@ def __str__(self):
class Meta:
unique_together = ["song", "user"]
ordering = ["-created"]
class UserListenHistory(models.Model):
user = models.ForeignKey(
"users.User", related_name="songs_listened", on_delete=models.CASCADE
)
song = models.ForeignKey("Song", related_name="listeners", on_delete=models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created"]

View File

@ -1,10 +1,11 @@
from asgiref.sync import async_to_sync
from celery import shared_task
from channels.layers import get_channel_layer
from django.utils.timezone import now
from pytube import Channel, Playlist
from akarpov.music.api.serializers import SongSerializer
from akarpov.music.models import RadioSong, Song
from akarpov.music.models import RadioSong, Song, UserListenHistory
from akarpov.music.services import yandex, youtube
from akarpov.music.services.file import load_dir, load_file
from akarpov.utils.celery import get_scheduled_tasks_name
@ -86,3 +87,29 @@ def start_next_song(previous_ids: list):
countdown=song.length,
)
return
@shared_task
def listen_to_song(song_id, user_id=None):
# protection from multiple listen,
# check that last listen by user was more than the length of the song
# and last listened song is not the same
s = Song.objects.get(id=song_id)
s.played += 1
s.save(update_fields=["played"])
if user_id:
try:
last_listen = UserListenHistory.objects.filter(user_id=user_id).latest("id")
except UserListenHistory.DoesNotExist:
last_listen = None
if (
last_listen
and last_listen.song_id == song_id
or last_listen.created + s.length > now()
):
return
UserListenHistory.objects.create(
user_id=user_id,
song_id=song_id,
)
return song_id

View File

@ -159,6 +159,35 @@
const toastContainer = document.getElementById('toastContainer')
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function timeSince(date) {
let seconds = Math.floor((new Date() - date) / 1000);
let interval = seconds / 31536000;
if (interval > 1) {
return Math.floor(interval) + " years";
}
interval = seconds / 2592000;
if (interval > 1) {
return Math.floor(interval) + " months";
}
interval = seconds / 86400;
if (interval > 1) {
return Math.floor(interval) + " days";
}
interval = seconds / 3600;
if (interval > 1) {
return Math.floor(interval) + " hours";
}
interval = seconds / 60;
if (interval > 1) {
return Math.floor(interval) + " minutes";
}
return Math.floor(seconds) + " seconds";
}
let fn = async function(event) {
let data = JSON.parse(event.data)
const toast = document.createElement("div")

View File

@ -73,10 +73,10 @@
"auth.*": {"ops": ("fetch", "get"), "timeout": 60 * 2},
"blog.post": {"ops": ("fetch", "get"), "timeout": 20 * 15},
"themes.theme": {"ops": ("fetch", "get"), "timeout": 60 * 60},
"gallery.*": {"ops": "all", "timeout": 60 * 15},
"gallery.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
"files.*": {"ops": ("fetch", "get"), "timeout": 60},
"auth.permission": {"ops": "all", "timeout": 60 * 15},
"music.*": {"ops": "all", "timeout": 60 * 15},
"music.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
}
CACHEOPS_REDIS = env.str("REDIS_URL")