diff --git a/akarpov/music/admin.py b/akarpov/music/admin.py index 6383daf..61b2ff4 100644 --- a/akarpov/music/admin.py +++ b/akarpov/music/admin.py @@ -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) diff --git a/akarpov/music/api/serializers.py b/akarpov/music/api/serializers.py index a78c63d..1bd7a1a 100644 --- a/akarpov/music/api/serializers.py +++ b/akarpov/music/api/serializers.py @@ -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 = [ diff --git a/akarpov/music/api/urls.py b/akarpov/music/api/urls.py index 72702bb..cdc8644 100644 --- a/akarpov/music/api/urls.py +++ b/akarpov/music/api/urls.py @@ -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//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/", diff --git a/akarpov/music/api/views.py b/akarpov/music/api/views.py index 3258c40..0567f8f 100644 --- a/akarpov/music/api/views.py +++ b/akarpov/music/api/views.py @@ -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) + ) diff --git a/akarpov/music/migrations/0011_alter_playlist_private_userlistenhistory.py b/akarpov/music/migrations/0011_alter_playlist_private_userlistenhistory.py new file mode 100644 index 0000000..7328ff1 --- /dev/null +++ b/akarpov/music/migrations/0011_alter_playlist_private_userlistenhistory.py @@ -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"], + }, + ), + ] diff --git a/akarpov/music/models.py b/akarpov/music/models.py index f585ea5..74589e0 100644 --- a/akarpov/music/models.py +++ b/akarpov/music/models.py @@ -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"] diff --git a/akarpov/music/tasks.py b/akarpov/music/tasks.py index 69b81cd..95c9520 100644 --- a/akarpov/music/tasks.py +++ b/akarpov/music/tasks.py @@ -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 diff --git a/akarpov/templates/base.html b/akarpov/templates/base.html index 4e76284..6186d0c 100644 --- a/akarpov/templates/base.html +++ b/akarpov/templates/base.html @@ -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") diff --git a/config/settings/base.py b/config/settings/base.py index a825c11..802966e 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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")