Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
dc5dc02472
Bump pymorphy3 from 1.2.1 to 1.3.1
Bumps [pymorphy3](https://github.com/no-plagiarism/pymorphy3) from 1.2.1 to 1.3.1.
- [Release notes](https://github.com/no-plagiarism/pymorphy3/releases)
- [Changelog](https://github.com/no-plagiarism/pymorphy3/blob/master/CHANGES.rst)
- [Commits](https://github.com/no-plagiarism/pymorphy3/compare/1.2.1...1.3.1)

---
updated-dependencies:
- dependency-name: pymorphy3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-17 21:44:37 +00:00
43a6e8d779 updated music processing 2023-12-18 00:35:58 +03:00
b16ec21486 fixed music artist and albums relations 2023-12-17 16:36:30 +03:00
18ba02caab fixed files 2023-12-17 16:22:40 +03:00
7b48929e38 fixed albums and authors, added related objects 2023-12-17 16:19:00 +03:00
127b4b6e11 major music updates, minor template fixes 2023-12-16 21:11:08 +03:00
13 changed files with 395 additions and 61 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 = [
@ -225,10 +239,26 @@ class Meta:
class FullAlbumSerializer(serializers.ModelSerializer):
songs = ListSongSerializer(many=True, read_only=True)
artists = serializers.SerializerMethodField("get_artists")
@extend_schema_field(AuthorSerializer(many=True))
def get_artists(self, obj):
artists = []
qs = Author.objects.cache().filter(
songs__id__in=obj.songs.cache().all().values("id").distinct()
)
for artist in qs:
if artist not in artists:
artists.append(artist)
return AuthorSerializer(
artists,
many=True,
).data
class Meta:
model = Album
fields = ["name", "link", "image", "songs"]
fields = ["name", "link", "image", "songs", "artists"]
extra_kwargs = {
"link": {"read_only": True},
"image": {"read_only": True},
@ -237,10 +267,27 @@ class Meta:
class FullAuthorSerializer(serializers.ModelSerializer):
songs = ListSongSerializer(many=True, read_only=True)
albums = serializers.SerializerMethodField(method_name="get_albums")
@extend_schema_field(AlbumSerializer(many=True))
def get_albums(self, obj):
qs = Album.objects.cache().filter(
songs__id__in=obj.songs.cache().all().values("id").distinct()
)
albums = []
for album in qs:
# TODO: rewrite to filter
if album not in albums:
albums.append(album)
return AlbumSerializer(
albums,
many=True,
).data
class Meta:
model = Author
fields = ["name", "link", "image", "songs"]
fields = ["name", "link", "image", "songs", "albums"]
extra_kwargs = {
"link": {"read_only": True},
"image": {"read_only": True},

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)
)
@ -210,9 +272,7 @@ class ListAlbumsAPIView(generics.ListAPIView):
serializer_class = AlbumSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [permissions.AllowAny]
def get_queryset(self):
return Album.objects.all()
queryset = Album.objects.cache().all()
class RetrieveUpdateDestroyAlbumAPIView(
@ -222,15 +282,14 @@ class RetrieveUpdateDestroyAlbumAPIView(
lookup_url_kwarg = "slug"
permission_classes = [IsAdminOrReadOnly]
serializer_class = FullAlbumSerializer
queryset = Album.objects.cache().all()
class ListAuthorsAPIView(generics.ListAPIView):
serializer_class = AuthorSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [permissions.AllowAny]
def get_queryset(self):
return Author.objects.all()
queryset = Author.objects.cache().all()
class RetrieveUpdateDestroyAuthorAPIView(
@ -240,3 +299,46 @@ class RetrieveUpdateDestroyAuthorAPIView(
lookup_url_kwarg = "slug"
permission_classes = [IsAdminOrReadOnly]
serializer_class = FullAuthorSerializer
queryset = Author.objects.cache().all()
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,6 +1,8 @@
import os
from deep_translator import GoogleTranslator
from django.core.files import File
from django.utils.text import slugify
from mutagen import File as MutagenFile
from mutagen.id3 import APIC, ID3, TCON, TORY, TextFrame
from mutagen.mp3 import MP3
@ -72,16 +74,32 @@ def load_track(
if kwargs:
song.meta = kwargs
new_file_name = (
str(
slugify(
GoogleTranslator(source="auto", target="en").translate(
f"{song.name} {' '.join([x.name for x in song.authors])}",
target_language="en",
)
)
)
+ ".mp3"
)
if image_path:
with open(path, "rb") as file, open(image_path, "rb") as image:
song.image = File(image, name=image_path.split("/")[-1])
song.file = File(file, name=path.split("/")[-1])
song.file = File(file, name=new_file_name)
song.save()
else:
with open(path, "rb") as file:
song.file = File(file, name=path.split("/")[-1])
song.file = File(file, name=new_file_name)
song.save()
if not album.image and song.image:
album.image = song.image
album.save()
if authors:
song.authors.set(authors)

View File

@ -3,7 +3,7 @@
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from akarpov.music.models import PlaylistSong, Song, SongUserRating
from akarpov.music.models import Album, Author, PlaylistSong, Song, SongUserRating
@receiver(post_delete, sender=Song)
@ -13,6 +13,20 @@ def auto_delete_file_on_delete(sender, instance, **kwargs):
os.remove(instance.file.path)
@receiver(post_save, sender=Author)
def author_create(sender, instance, created, **kwargs):
if created:
# TODO: add logic to retrieve author info here
return
@receiver(post_save, sender=Album)
def album_create(sender, instance, created, **kwargs):
if created:
# TODO: add logic to retrieve author info here
return
@receiver(post_save)
def send_que_status(sender, instance, created, **kwargs):
...

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},
"files.*": {"ops": ("fetch", "get"), "timeout": 60},
"gallery.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
"files.*": {"ops": ("fetch", "get", "list"), "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")

37
poetry.lock generated
View File

@ -1327,6 +1327,21 @@ files = [
{file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
]
[[package]]
name = "deep-translator"
version = "1.4.2"
description = "A flexible python tool to translate between different languages in a simple way."
optional = false
python-versions = ">=3.0"
files = [
{file = "deep_translator-1.4.2-py2.py3-none-any.whl", hash = "sha256:85ec4cf52b9a48bdabc992eb931545abd2a0c63e092e50773d14f7bc506e6e89"},
{file = "deep_translator-1.4.2.tar.gz", hash = "sha256:1af1feaaa351904e2db10d5fa86a09009839ad2252c0981150350d962d6b3dc4"},
]
[package.dependencies]
beautifulsoup4 = "*"
requests = "*"
[[package]]
name = "defusedxml"
version = "0.7.1"
@ -1950,17 +1965,6 @@ compatible-mypy = ["mypy (>=0.991,<0.1000)"]
coreapi = ["coreapi (>=2.0.0)"]
markdown = ["types-Markdown (>=0.1.5)"]
[[package]]
name = "docopt-ng"
version = "0.9.0"
description = "Jazzband-maintained fork of docopt, the humane command line arguments parser."
optional = false
python-versions = ">=3.7"
files = [
{file = "docopt_ng-0.9.0-py3-none-any.whl", hash = "sha256:bfe4c8b03f9fca424c24ee0b4ffa84bf7391cb18c29ce0f6a8227a3b01b81ff9"},
{file = "docopt_ng-0.9.0.tar.gz", hash = "sha256:91c6da10b5bb6f2e9e25345829fb8278c78af019f6fc40887ad49b060483b1d7"},
]
[[package]]
name = "docutils"
version = "0.20.1"
@ -4518,21 +4522,22 @@ pylint = ">=1.7"
[[package]]
name = "pymorphy3"
version = "1.2.1"
version = "1.3.1"
description = "Morphological analyzer (POS tagger + inflection engine) for Russian language."
optional = false
python-versions = "*"
files = [
{file = "pymorphy3-1.2.1-py3-none-any.whl", hash = "sha256:88700966f55e77e3d2aedf194fa00bb4a175c2626017fe423e94ce11bc98f1ff"},
{file = "pymorphy3-1.2.1.tar.gz", hash = "sha256:0cc186a3b0716129dd45e3b89f5e8339e5943d9013f93cfd4c58e5335daf296d"},
{file = "pymorphy3-1.3.1-py3-none-any.whl", hash = "sha256:bc495d245e6155b1ce77b8aa4d5a7d7e27c678d564338401f8f99271812c40c2"},
{file = "pymorphy3-1.3.1.tar.gz", hash = "sha256:49b5651aa76dadabe0fb2e6a1ef73619c0b434ed118916ce0211dd7a4cb60cd8"},
]
[package.dependencies]
dawg-python = ">=0.7.1"
docopt-ng = ">=0.6"
pymorphy3-dicts-ru = "*"
setuptools = {version = ">=68.2.2", markers = "python_version >= \"3.12\""}
[package.extras]
cli = ["click"]
fast = ["DAWG (>=0.8)"]
[[package]]
@ -6704,4 +6709,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "7b52426065b04709a15a560787f01a00166dba6005aea755ed490a7523569524"
content-hash = "4f36b172cecf2647893a5d7ac50bc14489b331b9a5bd11811094b9270842f6b0"

View File

@ -107,13 +107,14 @@ textract = "^1.6.5"
uuid6 = "^2023.5.2"
uvicorn = "^0.24.0.post1"
nltk = "^3.8.1"
pymorphy3 = "^1.2.1"
pymorphy3 = "^1.3.1"
pymorphy3-dicts-ru = "^2.4.417150.4580142"
fastapi = "^0.104.1"
pydantic-settings = "^2.0.3"
django-elasticsearch-dsl = "^8.0"
elasticsearch-dsl = "^8.11.0"
numpy = "1.25.2"
deep-translator = "1.4.2"
[build-system]