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 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(Author)
admin.site.register(Album) admin.site.register(Album)
admin.site.register(Song) admin.site.register(Song)
admin.site.register(Playlist) admin.site.register(Playlist)
admin.site.register(PlaylistSong) admin.site.register(PlaylistSong)
admin.site.register(SongUserRating)

View File

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

View File

@ -9,8 +9,11 @@
ListCreatePlaylistAPIView, ListCreatePlaylistAPIView,
ListCreateSongAPIView, ListCreateSongAPIView,
ListDislikedSongsAPIView, ListDislikedSongsAPIView,
ListenSongAPIView,
ListLikedSongsAPIView, ListLikedSongsAPIView,
ListPublicPlaylistAPIView, ListPublicPlaylistAPIView,
ListSongPlaylistsAPIView,
ListUserListenedSongsAPIView,
RemoveSongFromPlaylistAPIView, RemoveSongFromPlaylistAPIView,
RetrieveUpdateDestroyAlbumAPIView, RetrieveUpdateDestroyAlbumAPIView,
RetrieveUpdateDestroyAuthorAPIView, RetrieveUpdateDestroyAuthorAPIView,
@ -42,10 +45,25 @@
RetrieveUpdateDestroySongAPIView.as_view(), RetrieveUpdateDestroySongAPIView.as_view(),
name="retrieve_update_delete_song", name="retrieve_update_delete_song",
), ),
path("song/like/", LikeSongAPIView.as_view()), path(
path("song/dislike/", DislikeSongAPIView.as_view()), "song/<str:slug>/playlists/",
path("playlists/add/", AddSongToPlaylistAPIView.as_view()), ListSongPlaylistsAPIView.as_view(),
path("playlists/remove/", RemoveSongFromPlaylistAPIView.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/", ListAlbumsAPIView.as_view(), name="list_albums"),
path( path(
"albums/<str:slug>", "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 import generics, permissions
from rest_framework.response import Response
from akarpov.common.api.pagination import StandardResultsSetPagination from akarpov.common.api.pagination import StandardResultsSetPagination
from akarpov.common.api.permissions import IsAdminOrReadOnly, IsCreatorOrReadOnly from akarpov.common.api.permissions import IsAdminOrReadOnly, IsCreatorOrReadOnly
@ -15,7 +17,15 @@
PlaylistSerializer, PlaylistSerializer,
SongSerializer, 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): class LikedSongsContextMixin(generics.GenericAPIView):
@ -73,19 +83,76 @@ class ListCreateSongAPIView(LikedSongsContextMixin, generics.ListCreateAPIView):
pagination_class = StandardResultsSetPagination pagination_class = StandardResultsSetPagination
def get_queryset(self): 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: if self.request.user.is_authenticated:
return ( return qs.exclude(
Song.objects.all() id__in=SongUserRating.objects.filter(
.exclude( user=self.request.user,
id__in=SongUserRating.objects.filter( like=False,
user=self.request.user, ).values_list("song_id", flat=True)
like=False,
).values_list("song_id", flat=True)
)
.prefetch_related("authors")
.select_related("album")
) )
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): class RetrieveUpdateDestroySongAPIView(generics.RetrieveUpdateDestroyAPIView):
@ -142,15 +209,10 @@ def get_serializer_context(self):
return context return context
def get_queryset(self): def get_queryset(self):
return ( return Song.objects.cache().filter(
Song.objects.cache() id__in=SongUserRating.objects.cache()
.filter( .filter(user=self.request.user, like=True)
id__in=SongUserRating.objects.cache() .values_list("song_id", flat=False)
.filter(user=self.request.user, like=True)
.values_list("song_id", flat=False)
)
.prefetch_related("authors")
.select_related("album")
) )
@ -240,3 +302,45 @@ class RetrieveUpdateDestroyAuthorAPIView(
lookup_url_kwarg = "slug" lookup_url_kwarg = "slug"
permission_classes = [IsAdminOrReadOnly] permission_classes = [IsAdminOrReadOnly]
serializer_class = FullAuthorSerializer 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: class Meta:
unique_together = ["song", "user"] unique_together = ["song", "user"]
ordering = ["-created"] 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 asgiref.sync import async_to_sync
from celery import shared_task from celery import shared_task
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.utils.timezone import now
from pytube import Channel, Playlist from pytube import Channel, Playlist
from akarpov.music.api.serializers import SongSerializer 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 import yandex, youtube
from akarpov.music.services.file import load_dir, load_file from akarpov.music.services.file import load_dir, load_file
from akarpov.utils.celery import get_scheduled_tasks_name from akarpov.utils.celery import get_scheduled_tasks_name
@ -86,3 +87,29 @@ def start_next_song(previous_ids: list):
countdown=song.length, countdown=song.length,
) )
return 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') 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 fn = async function(event) {
let data = JSON.parse(event.data) let data = JSON.parse(event.data)
const toast = document.createElement("div") const toast = document.createElement("div")

View File

@ -73,10 +73,10 @@
"auth.*": {"ops": ("fetch", "get"), "timeout": 60 * 2}, "auth.*": {"ops": ("fetch", "get"), "timeout": 60 * 2},
"blog.post": {"ops": ("fetch", "get"), "timeout": 20 * 15}, "blog.post": {"ops": ("fetch", "get"), "timeout": 20 * 15},
"themes.theme": {"ops": ("fetch", "get"), "timeout": 60 * 60}, "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}, "files.*": {"ops": ("fetch", "get"), "timeout": 60},
"auth.permission": {"ops": "all", "timeout": 60 * 15}, "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") CACHEOPS_REDIS = env.str("REDIS_URL")