Compare commits

...

3 Commits

24 changed files with 2197 additions and 1328 deletions

View File

@ -1,5 +1,8 @@
from rest_framework import serializers from rest_framework import serializers
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import SAFE_METHODS, BasePermission
from akarpov.utils.models import get_object_user
class SmallResultsSetPagination(PageNumberPagination): class SmallResultsSetPagination(PageNumberPagination):
@ -24,3 +27,19 @@ class RecursiveField(serializers.Serializer):
def to_representation(self, value): def to_representation(self, value):
serializer = self.parent.parent.__class__(value, context=self.context) serializer = self.parent.parent.__class__(value, context=self.context)
return serializer.data return serializer.data
class IsCreatorOrReadOnly(BasePermission):
def has_permission(self, request, view):
return bool(
request.method in SAFE_METHODS
or request.user
and get_object_user(view.get_object()) == request.user
)
class SetUserModelSerializer(serializers.ModelSerializer):
def create(self, validated_data):
creator = self.context["request"].user
obj = self.Meta.model.objects.create(creator=creator, **validated_data)
return obj

View File

@ -1,11 +1,15 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from akarpov.music.models import Album, Author, Song from akarpov.common.api import SetUserModelSerializer
from akarpov.music.models import Album, Author, Playlist, Song
from akarpov.users.api.serializers import UserPublicInfoSerializer
class AuthorSerializer(serializers.ModelSerializer): class AuthorSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField(method_name="get_url") url = serializers.SerializerMethodField(method_name="get_url")
@extend_schema_field(serializers.URLField)
def get_url(self, obj): def get_url(self, obj):
return obj.get_absolute_url() return obj.get_absolute_url()
@ -17,6 +21,7 @@ class Meta:
class AlbumSerializer(serializers.ModelSerializer): class AlbumSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField(method_name="get_url") url = serializers.SerializerMethodField(method_name="get_url")
@extend_schema_field(serializers.URLField)
def get_url(self, obj): def get_url(self, obj):
return obj.get_absolute_url() return obj.get_absolute_url()
@ -32,7 +37,6 @@ class SongSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Song model = Song
fields = [ fields = [
"id",
"image", "image",
"link", "link",
"length", "length",
@ -42,3 +46,48 @@ class Meta:
"authors", "authors",
"album", "album",
] ]
extra_kwargs = {
"slug": {"read_only": True},
"creator": {"read_only": True},
"length": {"read_only": True},
"played": {"read_only": True},
}
class ListSongSerializer(SetUserModelSerializer):
album = serializers.CharField(source="album.name", read_only=True)
class Meta:
model = Song
fields = ["name", "slug", "file", "image_cropped", "length", "album"]
extra_kwargs = {
"slug": {"read_only": True},
"image_cropped": {"read_only": True},
"length": {"read_only": True},
"album": {"read_only": True},
}
class PlaylistSerializer(SetUserModelSerializer):
creator = UserPublicInfoSerializer()
class Meta:
model = Playlist
fields = ["name", "slug", "private", "creator"]
extra_kwargs = {
"slug": {"read_only": True},
"creator": {"read_only": True},
}
class FullPlaylistSerializer(serializers.ModelSerializer):
songs = ListSongSerializer(many=True, read_only=True)
creator = UserPublicInfoSerializer(read_only=True)
class Meta:
model = Playlist
fields = ["name", "private", "creator", "songs"]
extra_kwargs = {
"slug": {"read_only": True},
"creator": {"read_only": True},
}

17
akarpov/music/api/urls.py Normal file
View File

@ -0,0 +1,17 @@
from django.urls import path
from akarpov.music.api.views import (
ListCreatePlaylistAPIView,
ListCreateSongAPIView,
RetrieveUpdateDestroyPlaylistAPIView,
RetrieveUpdateDestroySongAPIView,
)
app_name = "music"
urlpatterns = [
path("playlists/", ListCreatePlaylistAPIView.as_view()),
path("playlists/<str:slug>", RetrieveUpdateDestroyPlaylistAPIView.as_view()),
path("song/", ListCreateSongAPIView.as_view()),
path("song/<str:slug>", RetrieveUpdateDestroySongAPIView.as_view()),
]

View File

@ -0,0 +1,58 @@
from rest_framework import generics, permissions
from akarpov.common.api import IsCreatorOrReadOnly
from akarpov.music.api.serializers import (
FullPlaylistSerializer,
ListSongSerializer,
PlaylistSerializer,
SongSerializer,
)
from akarpov.music.models import Playlist, Song
class ListCreatePlaylistAPIView(generics.ListCreateAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = PlaylistSerializer
def get_queryset(self):
return Playlist.objects.filter(creator=self.request.user)
class RetrieveUpdateDestroyPlaylistAPIView(generics.RetrieveUpdateDestroyAPIView):
lookup_field = "slug"
lookup_url_kwarg = "slug"
permission_classes = [IsCreatorOrReadOnly]
serializer_class = FullPlaylistSerializer
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.object = None
def get_object(self):
if not self.object:
self.object = super().get_object()
return self.object
class ListCreateSongAPIView(generics.ListCreateAPIView):
serializer_class = ListSongSerializer
permission_classes = [IsCreatorOrReadOnly]
def get_queryset(self):
return Song.objects.all()
class RetrieveUpdateDestroySongAPIView(generics.RetrieveUpdateDestroyAPIView):
lookup_field = "slug"
lookup_url_kwarg = "slug"
permission_classes = [IsCreatorOrReadOnly]
serializer_class = SongSerializer
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.object = None
def get_object(self):
if not self.object:
self.object = super().get_object()
return self.object

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.5 on 2023-09-27 08:06
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", "0006_tempfileupload"),
]
operations = [
migrations.AddField(
model_name="song",
name="creator",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="songs",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.5 on 2023-09-29 15:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0007_song_creator"),
]
operations = [
migrations.AddField(
model_name="song",
name="meta",
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.5 on 2023-09-30 11:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0008_song_meta"),
]
operations = [
migrations.AlterField(
model_name="songinque",
name="name",
field=models.CharField(blank=True, max_length=500),
),
migrations.AlterField(
model_name="songinque",
name="status",
field=models.CharField(blank=True, max_length=500, null=True),
),
]

View File

@ -4,6 +4,7 @@
from akarpov.common.models import BaseImageModel from akarpov.common.models import BaseImageModel
from akarpov.tools.shortener.models import ShortLinkModel from akarpov.tools.shortener.models import ShortLinkModel
from akarpov.users.services.history import UserHistoryModel from akarpov.users.services.history import UserHistoryModel
from akarpov.utils.cache import cache_model_property
class Author(BaseImageModel, ShortLinkModel): class Author(BaseImageModel, ShortLinkModel):
@ -38,10 +39,44 @@ class Song(BaseImageModel, ShortLinkModel):
album = models.ForeignKey( album = models.ForeignKey(
Album, null=True, related_name="songs", on_delete=models.SET_NULL Album, null=True, related_name="songs", on_delete=models.SET_NULL
) )
creator = models.ForeignKey(
"users.User", related_name="songs", on_delete=models.SET_NULL, null=True
)
meta = models.JSONField(blank=True, null=True)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("music:song", kwargs={"slug": self.slug}) return reverse("music:song", kwargs={"slug": self.slug})
@property
def full_props(self):
if self.album_name and self.artists_names:
return f"{self.album_name} - {self.artists_names}"
elif self.album_name:
return self.album_name
elif self.artists_names:
return self.artists_names
return ""
@property
def _album_name(self):
if self.album and self.album.name:
return self.album.name
return ""
@property
def _authors_names(self):
if self.authors:
return ", ".join(self.authors.values_list("name", flat=True))
return ""
@property
def album_name(self):
return cache_model_property(self, "_album_name")
@property
def artists_names(self):
return cache_model_property(self, "_authors_names")
def __str__(self): def __str__(self):
return self.name return self.name
@ -80,8 +115,8 @@ class Meta:
class SongInQue(models.Model): class SongInQue(models.Model):
name = models.CharField(blank=True, max_length=250) name = models.CharField(blank=True, max_length=500)
status = models.CharField(null=True, blank=True, max_length=250) status = models.CharField(null=True, blank=True, max_length=500)
error = models.BooleanField(default=False) error = models.BooleanField(default=False)

View File

@ -1,11 +1,11 @@
from akarpov.music.tasks import list_tracks, process_dir, process_file from akarpov.music.tasks import list_tracks, process_dir, process_file
def load_tracks(address: str): def load_tracks(address: str, user_id: int):
if address.startswith("/"): if address.startswith("/"):
process_dir.apply_async(kwargs={"path": address}) process_dir.apply_async(kwargs={"path": address, "user_id": user_id})
list_tracks.apply_async(kwargs={"url": address}) list_tracks.apply_async(kwargs={"url": address, "user_id": user_id})
def load_track_file(file): def load_track_file(file, user_id: int):
process_file.apply_async(kwargs={"path": file}) process_file.apply_async(kwargs={"path": file, "user_id": user_id})

View File

@ -13,6 +13,7 @@
def load_track( def load_track(
path: str, path: str,
image_path: str | None = None, image_path: str | None = None,
user_id: int | None = None,
authors: list[str] | str | None = None, authors: list[str] | str | None = None,
album: str | None = None, album: str | None = None,
name: str | None = None, name: str | None = None,
@ -20,12 +21,19 @@ def load_track(
**kwargs, **kwargs,
) -> Song: ) -> Song:
p_name = path.split("/")[-1] p_name = path.split("/")[-1]
if album and type(album) is str and album.startswith("['"):
album = album.replace("['", "").replace("']", "")
if authors: if authors:
authors = [Author.objects.get_or_create(name=x)[0] for x in authors if authors] authors = [Author.objects.get_or_create(name=x)[0] for x in authors if authors]
else: else:
authors = [] authors = []
if album: if album:
if type(album) is str:
album = Album.objects.get_or_create(name=album)[0] album = Album.objects.get_or_create(name=album)[0]
elif type(album) is list:
album = Album.objects.get_or_create(name=album[0])[0]
else: else:
album = None album = None
@ -45,9 +53,11 @@ def load_track(
tag = MP3(path, ID3=ID3) tag = MP3(path, ID3=ID3)
if image_path: if image_path:
if not image_path.endswith(".png"): if not image_path.endswith(".png"):
nm = image_path
im = Image.open(image_path) im = Image.open(image_path)
image_path = image_path.replace(image_path.split(".")[-1], "png") image_path = image_path.replace(image_path.split(".")[-1], "png")
im.save(image_path) im.save(image_path)
os.remove(nm)
song = Song( song = Song(
link=link if link else "", link=link if link else "",
@ -56,6 +66,12 @@ def load_track(
album=album, album=album,
) )
if user_id:
song.user_id = user_id
if kwargs:
song.meta = kwargs
if image_path: if image_path:
with open(path, "rb") as file, open(image_path, "rb") as image: with open(path, "rb") as file, open(image_path, "rb") as image:
song.image = File(image, name=image_path.split("/")[-1]) song.image = File(image, name=image_path.split("/")[-1])
@ -96,4 +112,10 @@ def load_track(
tag.tags.add(TCON(text=kwargs["genre"])) tag.tags.add(TCON(text=kwargs["genre"]))
tag.save() tag.save()
if os.path.exists(path):
os.remove(path)
if os.path.exists(image_path):
os.remove(image_path)
return song return song

View File

@ -11,18 +11,18 @@
from akarpov.music.services.db import load_track from akarpov.music.services.db import load_track
def load_dir(path: str): def load_dir(path: str, user_id: int):
path = Path(path) path = Path(path)
for f in list(path.glob("**/*.mp3")): for f in list(path.glob("**/*.mp3")):
process_mp3_file(str(f)) process_mp3_file(str(f), user_id=user_id)
def load_file(path: str): def load_file(path: str, user_id: int):
process_mp3_file(path) process_mp3_file(path, user_id)
def process_mp3_file(path: str) -> None: def process_mp3_file(path: str, user_id: int) -> None:
tag = mutagen.File(path, easy=True) tag = mutagen.File(path, easy=True)
if "artist" in tag: if "artist" in tag:
author = tag["artist"] author = tag["artist"]
@ -55,6 +55,6 @@ def process_mp3_file(path: str) -> None:
im.save(image_pth) im.save(image_pth)
except UnidentifiedImageError: except UnidentifiedImageError:
pass pass
load_track(path, image_pth, author, album, name) load_track(path, image_pth, user_id, author, album, name)
if image_pth and os.path.exists(image_pth): if image_pth and os.path.exists(image_pth):
os.remove(image_pth) os.remove(image_pth)

View File

@ -1,18 +1,13 @@
import os import os
from pathlib import Path
from random import randint from random import randint
from django.conf import settings from django.conf import settings
from django.core.files import File
from django.utils.text import slugify from django.utils.text import slugify
from mutagen.easyid3 import EasyID3
from mutagen.id3 import APIC, ID3, TCON, TORY
from mutagen.mp3 import MP3
from pydub import AudioSegment
from yandex_music import Client, Playlist, Search, Track from yandex_music import Client, Playlist, Search, Track
from akarpov.music import tasks from akarpov.music import tasks
from akarpov.music.models import Album, Author, Song, SongInQue from akarpov.music.models import Song, SongInQue
from akarpov.music.services.db import load_track
def login() -> Client: def login() -> Client:
@ -48,9 +43,8 @@ def search_ym(name: str):
return info return info
def load_file_meta(track: int): def load_file_meta(track: int, user_id: int):
que = SongInQue.objects.create() que = SongInQue.objects.create()
try:
client = login() client = login()
track = client.tracks(track)[0] # type: Track track = client.tracks(track)[0] # type: Track
que.name = track.title que.name = track.title
@ -67,69 +61,38 @@ def load_file_meta(track: int):
return return
filename = slugify(f"{track.artists[0].name} - {track.title}") filename = slugify(f"{track.artists[0].name} - {track.title}")
orig_path = f"{settings.MEDIA_ROOT}/{filename}" orig_path = f"{settings.MEDIA_ROOT}/{filename}.mp3"
album = track.albums[0]
track.download(filename=orig_path, codec="mp3") track.download(filename=orig_path, codec="mp3")
path = orig_path + ".mp3"
AudioSegment.from_file(orig_path).export(path)
os.remove(orig_path)
# load album image
img_pth = str(settings.MEDIA_ROOT + f"/_{str(randint(10000, 99999))}.png") img_pth = str(settings.MEDIA_ROOT + f"/_{str(randint(10000, 99999))}.png")
track.download_cover(filename=img_pth) track.download_cover(filename=img_pth)
song = load_track(
album = track.albums[0] orig_path,
img_pth,
# set music meta user_id,
tag = MP3(path, ID3=ID3) [x.name for x in track.artists],
tag.tags.add( album.title,
APIC( track.title,
encoding=3, # 3 is for utf-8 release=album.release_date,
mime="image/png", # image/jpeg or image/png genre=album.genre,
type=3, # 3 is for the cover image
desc="Cover",
data=open(img_pth, "rb").read(),
) )
) if os.path.exists(orig_path):
tag.tags.add(TORY(text=str(album.year))) os.remove(orig_path)
tag.tags.add(TCON(text=album.genre)) if os.path.exists(img_pth):
tag.save()
os.remove(img_pth) os.remove(img_pth)
tag = EasyID3(path)
tag["title"] = track.title return str(song)
tag["album"] = album.title
tag["artist"] = track.artists[0].name
tag.save()
# save track
ms_path = Path(path)
song = Song(
name=track.title,
author=Author.objects.get_or_create(name=track.artists[0].name)[0],
album=Album.objects.get_or_create(name=album.title)[0],
)
with ms_path.open(mode="rb") as f:
song.file = File(f, name=ms_path.name)
song.save()
os.remove(path)
que.delete()
return song
except Exception as e:
que.name = e
que.error = True
que.save()
def load_playlist(link: str): def load_playlist(link: str, user_id: int):
author = link.split("/")[4] author = link.split("/")[4]
playlist_id = link.split("/")[-1] playlist_id = link.split("/")[-1]
client = login() client = login()
playlist = client.users_playlists(int(playlist_id), author) # type: Playlist playlist = client.users_playlists(int(playlist_id), author) # type: Playlist
for track in playlist.fetch_tracks(): for track in playlist.fetch_tracks():
tasks.load_ym_file_meta.apply_async(kwargs={"track": track.track.id}) tasks.load_ym_file_meta.apply_async(
kwargs={"track": track.track.id, "user_id": user_id}
)

View File

@ -64,7 +64,7 @@ def parse_description(description: str) -> list:
return list_of_chapters return list_of_chapters
def download_from_youtube_link(link: str) -> Song: def download_from_youtube_link(link: str, user_id: int) -> Song:
song = None song = None
with YoutubeDL(ydl_opts) as ydl: with YoutubeDL(ydl_opts) as ydl:
@ -118,6 +118,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track( song = load_track(
chapter_path, chapter_path,
f"{img_pth}.png", f"{img_pth}.png",
user_id,
info["artists"], info["artists"],
info["album_name"], info["album_name"],
chapters[i][2], chapters[i][2],
@ -127,6 +128,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track( song = load_track(
chapter_path, chapter_path,
f"{img_pth}.png", f"{img_pth}.png",
user_id,
info["artists"], info["artists"],
info["album_name"], info["album_name"],
chapters[i][2], chapters[i][2],
@ -152,6 +154,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track( song = load_track(
path, path,
f"{img_pth}.png", f"{img_pth}.png",
user_id,
info["artists"], info["artists"],
info["album_name"], info["album_name"],
title, title,
@ -161,6 +164,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track( song = load_track(
path, path,
f"{img_pth}.png", f"{img_pth}.png",
user_id,
info["artists"], info["artists"],
info["album_name"], info["album_name"],
title, title,

View File

@ -11,44 +11,44 @@
@shared_task @shared_task
def list_tracks(url): def list_tracks(url, user_id):
if "music.yandex.ru" in url: if "music.yandex.ru" in url:
yandex.load_playlist(url) yandex.load_playlist(url, user_id)
elif "channel" in url or "/c/" in url: elif "channel" in url or "/c/" in url:
p = Channel(url) p = Channel(url)
for video in p.video_urls: for video in p.video_urls:
process_yb.apply_async(kwargs={"url": video}) process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
elif "playlist" in url or "&list=" in url: elif "playlist" in url or "&list=" in url:
p = Playlist(url) p = Playlist(url)
for video in p.video_urls: for video in p.video_urls:
process_yb.apply_async(kwargs={"url": video}) process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
else: else:
process_yb.apply_async(kwargs={"url": url}) process_yb.apply_async(kwargs={"url": url, "user_id": user_id})
return url return url
@shared_task(max_retries=5) @shared_task(max_retries=5)
def process_yb(url): def process_yb(url, user_id):
youtube.download_from_youtube_link(url) youtube.download_from_youtube_link(url, user_id)
return url return url
@shared_task @shared_task
def process_dir(path): def process_dir(path, user_id):
load_dir(path) load_dir(path, user_id)
return path return path
@shared_task @shared_task
def process_file(path): def process_file(path, user_id):
load_file(path) load_file(path, user_id)
return path return path
@shared_task @shared_task
def load_ym_file_meta(track): def load_ym_file_meta(track, user_id):
return yandex.load_file_meta(track) return yandex.load_file_meta(track, user_id)
@shared_task() @shared_task()

View File

@ -12,4 +12,5 @@
path("author/<str:slug>", views.author_view, name="author"), path("author/<str:slug>", views.author_view, name="author"),
path("playlist/<str:slug>", views.playlist_view, name="playlist"), path("playlist/<str:slug>", views.playlist_view, name="playlist"),
path("radio/", views.radio_main_view, name="radio"), path("radio/", views.radio_main_view, name="radio"),
path("player/", views.music_player_view, name="player"),
] ]

View File

@ -55,7 +55,7 @@ def get_success_url(self):
return "" return ""
def form_valid(self, form): def form_valid(self, form):
load_tracks(form.data["address"]) load_tracks(form.data["address"], user_id=self.request.user.id)
return super().form_valid(form) return super().form_valid(form)
@ -73,7 +73,7 @@ def get_success_url(self):
def form_valid(self, form): def form_valid(self, form):
for file in form.cleaned_data["file"]: for file in form.cleaned_data["file"]:
t = TempFileUpload.objects.create(file=file) t = TempFileUpload.objects.create(file=file)
load_track_file(t.file.path) load_track_file(t.file.path, user_id=self.request.user.id)
return super().form_valid(form) return super().form_valid(form)
@ -86,3 +86,14 @@ class MainRadioView(generic.TemplateView):
radio_main_view = MainRadioView.as_view() radio_main_view = MainRadioView.as_view()
class MusicPlayerView(generic.ListView):
template_name = "music/player.html"
model = Song
def get_queryset(self):
return Song.objects.all()
music_player_view = MusicPlayerView.as_view()

View File

@ -0,0 +1,67 @@
@import url('https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css');
*,*:before,*:after{outline:0;-webkit-box-sizing:border-box;box-sizing:border-box;}
input,button{outline:none;}
a,a:hover,a:visited{color:#ddd;text-decoration:none;}
.flex{display:-webkit-flex;display:flex;}
.flex-wrap{display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;}
.flex-align{-webkit-align-items:center;align-items:center;}
.w-full{width:100%;}
/* HTML5 Audio Player with Playlist, source: https://codepen.io/sekedus/pen/ExxjZEz */
#simp button,#simp input,#simp img{border:0;}
#simp{max-width:600px;font-size:14px;font-family:"Segoe UI", Tahoma, sans-serif;text-align:initial;line-height:initial;background:#17212b;color:#ddd;margin:0 auto;border-radius:6px;overflow:hidden;}
#simp .simp-album{padding:20px 25px 5px;}
#simp .simp-album .simp-cover{margin-right:20px;}
#simp .simp-album .simp-cover img{max-width:80px;width:100%;margin:0;padding:0;display:block;}
#simp .simp-album .simp-title{font-size:120%;font-weight:bold;}
#simp .simp-album .simp-artist{font-size:90%;color:#6c7883;}
#simp .simp-controls{padding:15px;}
#simp .simp-controls button{font-size:130%;width:32px;height:32px;background:none;color:#ddd;padding:7px;cursor:pointer;border:0;border-radius:3px;}
#simp .simp-controls button[disabled]{color:#636469;cursor:initial;}
#simp .simp-controls button:not([disabled]):hover{background:#4082bc;color:#fff;}
#simp .simp-controls .simp-prev,#simp .simp-controls .simp-next{font-size:100%;}
#simp .simp-controls .simp-tracker,#simp .simp-controls .simp-volume{flex:1;margin-left:10px;position:relative;}
#simp .simp-controls .simp-buffer {position:absolute;top:50%;right:0;left:0;height:5px;margin-top:-2.5px;border-radius:100px;}
#simp .simp-controls .simp-loading .simp-buffer {-webkit-animation:audio-progress 1s linear infinite;animation:audio-progress 1s linear infinite;background-image: linear-gradient(-45deg, #000 25%, transparent 25%, transparent 50%, #000 50%, #000 75%, transparent 75%, transparent);background-repeat:repeat-x;background-size:25px 25px;color:transparent;}
#simp .simp-controls .simp-time,#simp .simp-controls .simp-others{margin-left:10px;}
#simp .simp-controls .simp-volume{max-width:110px;}
#simp .simp-controls .simp-volume .simp-mute{margin-right:5px;}
#simp .simp-controls .simp-others .simp-active{background:#242f3d;}
#simp .simp-controls .simp-others .simp-shide button{font-size:100%;padding:0;width:24px;height:14px;display:block;}
#simp .simp-controls input[type=range]{-webkit-appearance:none;background:transparent;height:19px;margin:0;width:100%;display:block;position:relative;z-index:2;}
#simp .simp-controls input[type=range]::-webkit-slider-runnable-track{background:rgba(183,197,205,.66);height:5px;border-radius:2.5px;transition:box-shadow .3s ease;position:relative;}
#simp .simp-controls input[type=range]::-moz-range-track{background:rgba(183,197,205,.66);height:5px;border-radius:2.5px;transition:box-shadow .3s ease;position:relative;}
#simp .simp-controls .simp-load .simp-progress::-webkit-slider-runnable-track{background:#2f3841;}
#simp .simp-controls .simp-load .simp-progress::-moz-range-track{background:#2f3841;}
#simp .simp-controls .simp-loading .simp-progress::-webkit-slider-runnable-track{background:rgba(255,255,255,.25);}
#simp .simp-controls .simp-loading .simp-progress::-moz-range-track{background:rgba(255,255,255,.25);}
#simp .simp-controls input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;background:#fff;height:13px;width:13px;margin-top:-4px;cursor:pointer;border-radius:50%;box-shadow:0 1px 1px rgba(0,0,0,.15), 0 0 0 1px rgba(47,52,61,.2);}
#simp .simp-controls input[type=range]::-moz-range-thumb{-webkit-appearance:none;background:#fff;height:13px;width:13px;cursor:pointer;border-radius:50%;box-shadow:0 1px 1px rgba(0,0,0,.15), 0 0 0 1px rgba(47,52,61,.2);}
#simp .simp-footer{padding:10px 10px 12px;font-size:90%;text-align:center;opacity:.7;}
#simp .simp-display{overflow:hidden;max-height:650px;transition:max-height .5s ease-in-out;}
#simp .simp-hide{max-height:0;}
/* playlist */
#simp ul{margin:5px 0 0;padding:0;list-style:none;max-height:245px;}
#simp ul li{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;margin:0;padding:8px 20px;cursor:pointer;}
#simp ul li:last-child{padding-bottom:13px;}
#simp ul li:nth-child(odd){background:#0e1621;}
#simp ul li:hover{background:#242f3d;}
#simp ul li.simp-active{background:#4082bc;color:#fff;}
#simp ul li .simp-desc{font-size:90%;opacity:.5;margin-left:5px;}
/* playlist scrollbar */
#simp ul{overflow-y:auto;overflow-x:hidden;scrollbar-color:#73797f #2f3841;}
#simp ul::-webkit-scrollbar-track{background-color:#2f3841;}
#simp ul::-webkit-scrollbar{width:6px;background-color:#2f3841;}
#simp ul::-webkit-scrollbar-thumb{background-color:#73797f;}
/* progress animation */
@-webkit-keyframes audio-progress{to{background-position:25px 0;}}
@keyframes audio-progress{to{background-position:25px 0;}}
/* mobile */
@media screen and (max-width:480px) {
#simp .simp-controls .simp-volume,#simp .simp-controls .simp-others{display:none;}
#simp .simp-controls .simp-time{margin-right:10px;}
}
@media screen and (max-width:370px) {
#simp .simp-time .simp-slash,#simp .simp-time .end-time{display:none;}
}

View File

@ -0,0 +1,415 @@
function addEventListener_multi(element, eventNames, handler) {
const events = eventNames.split(' ');
events.forEach(e => element.addEventListener(e, handler, false));
}
// Random numbers in a specific range
function getRandom(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Position element inside element
function getRelativePos(elm) {
const pPos = elm.parentNode.getBoundingClientRect(); // parent pos
const cPos = elm.getBoundingClientRect(); // target pos
const pos = {};
pos.top = cPos.top - pPos.top + elm.parentNode.scrollTop,
pos.right = cPos.right - pPos.right,
pos.bottom = cPos.bottom - pPos.bottom,
pos.left = cPos.left - pPos.left;
return pos;
}
function formatTime(val) {
let h = 0, m = 0, s;
val = parseInt(val, 10);
if (val > 60 * 60) {
h = parseInt(val / (60 * 60), 10);
val -= h * 60 * 60;
}
if (val > 60) {
m = parseInt(val / 60, 10);
val -= m * 60;
}
s = val;
val = (h > 0)? h + ':' : '';
val += (m > 0)? ((m < 10 && h > 0)? '0' : '') + m + ':' : '0:';
val += ((s < 10)? '0' : '') + s;
return val;
}
function simp_initTime() {
simp_controls.querySelector('.start-time').innerHTML = formatTime(simp_audio.currentTime); //calculate current value time
if (!simp_isStream) {
simp_controls.querySelector('.end-time').innerHTML = formatTime(simp_audio.duration); //calculate total value time
simp_progress.value = simp_audio.currentTime / simp_audio.duration * 100; //progress bar
}
// ended of the audio
if (simp_audio.currentTime == simp_audio.duration) {
simp_controls.querySelector('.simp-plause').classList.remove('fa-pause');
simp_controls.querySelector('.simp-plause').classList.add('fa-play');
simp_audio.removeEventListener('timeupdate', simp_initTime);
if (simp_isNext) { //auto load next audio
let elem;
simp_a_index++;
if (simp_a_index == simp_a_url.length) { //repeat all audio
simp_a_index = 0;
elem = simp_a_url[0];
} else {
elem = simp_a_url[simp_a_index];
}
simp_changeAudio(elem);
simp_setAlbum(simp_a_index);
} else {
simp_isPlaying = false;
}
}
}
function simp_initAudio() {
// if readyState more than 2, audio file has loaded
simp_isLoaded = simp_audio.readyState == 4 ? true : false;
simp_isStream = simp_audio.duration == 'Infinity' ? true : false;
simp_controls.querySelector('.simp-plause').disabled = false;
simp_progress.disabled = simp_isStream ? true : false;
if (!simp_isStream) {
simp_progress.parentNode.classList.remove('simp-load','simp-loading');
simp_controls.querySelector('.end-time').innerHTML = formatTime(simp_audio.duration);
}
simp_audio.addEventListener('timeupdate', simp_initTime); //tracking load progress
if (simp_isLoaded && simp_isPlaying) simp_audio.play();
// progress bar click event
addEventListener_multi(simp_progress, 'touchstart mousedown', function(e) {
if (simp_isStream) {
e.stopPropagation();
return false;
}
if (simp_audio.readyState == 4) {
simp_audio.removeEventListener('timeupdate', simp_initTime);
simp_audio.pause();
}
});
addEventListener_multi(simp_progress, 'touchend mouseup', function(e) {
if (simp_isStream) {
e.stopPropagation();
return false;
}
if (simp_audio.readyState == 4) {
simp_audio.currentTime = simp_progress.value * simp_audio.duration / 100;
simp_audio.addEventListener('timeupdate', simp_initTime);
if (simp_isPlaying) simp_audio.play();
}
});
}
function simp_loadAudio(elem) {
simp_progress.parentNode.classList.add('simp-loading');
simp_controls.querySelector('.simp-plause').disabled = true;
simp_audio.querySelector('source').src = elem.dataset.src;
simp_audio.load();
simp_audio.volume = parseFloat(simp_v_num / 100); //based on valume input value
simp_audio.addEventListener('canplaythrough', simp_initAudio); //play audio without stop for buffering
// if audio fails to load, only IE/Edge 9.0 or above
simp_audio.addEventListener('error', function() {
alert('Please reload the page.');
});
}
function simp_setAlbum(index) {
simp_cover.innerHTML = simp_a_url[index].dataset.cover ? '<div style="background:url(' + simp_a_url[index].dataset.cover + ') no-repeat;background-size:cover;width:80px;height:80px;"></div>' : '<i class="fa fa-music fa-5x"></i>';
simp_title.innerHTML = simp_source[index].querySelector('.simp-source').innerHTML;
simp_artist.innerHTML = simp_source[index].querySelector('.simp-desc') ? simp_source[index].querySelector('.simp-desc').innerHTML : '';
}
function simp_changeAudio(elem) {
simp_isLoaded = false;
simp_controls.querySelector('.simp-prev').disabled = simp_a_index == 0 ? true : false;
simp_controls.querySelector('.simp-plause').disabled = simp_auto_load ? true : false;
simp_controls.querySelector('.simp-next').disabled = simp_a_index == simp_a_url.length-1 ? true : false;
simp_progress.parentNode.classList.add('simp-load');
simp_progress.disabled = true;
simp_progress.value = 0;
simp_controls.querySelector('.start-time').innerHTML = '00:00';
simp_controls.querySelector('.end-time').innerHTML = '00:00';
elem = simp_isRandom && simp_isNext ? simp_a_url[getRandom(0, simp_a_url.length-1)] : elem;
// playlist, audio is running
for (let i = 0; i < simp_a_url.length; i++) {
simp_a_url[i].parentNode.classList.remove('simp-active');
if (simp_a_url[i] == elem) {
simp_a_index = i;
simp_a_url[i].parentNode.classList.add('simp-active');
}
}
// scrolling to element inside element
const simp_active = getRelativePos(simp_source[simp_a_index]);
simp_source[simp_a_index].parentNode.scrollTop = simp_active.top;
if (simp_auto_load || simp_isPlaying) simp_loadAudio(elem);
if (simp_isPlaying) {
simp_controls.querySelector('.simp-plause').classList.remove('fa-play');
simp_controls.querySelector('.simp-plause').classList.add('fa-pause');
}
// set native audio properties
if('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: elem.textContent,
artist: elem.dataset.artists,
album: elem.dataset.album,
artwork: [
{ src: elem.dataset.cover, sizes: '96x96', type: 'image/png' },
{ src: elem.dataset.cover, sizes: '128x128', type: 'image/png' },
{ src: elem.dataset.cover, sizes: '192x192', type: 'image/png' },
{ src: elem.dataset.cover, sizes: '256x256', type: 'image/png' },
{ src: elem.dataset.cover, sizes: '384x384', type: 'image/png' },
{ src: elem.dataset.cover, sizes: '512x512', type: 'image/png' }
]
});
navigator.mediaSession.setActionHandler('play', () => {
let eles = document.getElementById("simp-plause").classList
if (simp_audio.paused) {
if (!simp_isLoaded) simp_loadAudio(simp_a_url[simp_a_index]);
simp_audio.play();
simp_isPlaying = true;
eles.remove('fa-play');
eles.add('fa-pause');
} else {
simp_audio.pause();
simp_isPlaying = false;
eles.remove('fa-pause');
eles.add('fa-play');
}
});
navigator.mediaSession.setActionHandler('pause', () => {
let eles = document.getElementById("simp-plause").classList
if (simp_audio.paused) {
if (!simp_isLoaded) simp_loadAudio(simp_a_url[simp_a_index]);
simp_audio.play();
simp_isPlaying = true;
eles.remove('fa-play');
eles.add('fa-pause');
} else {
simp_audio.pause();
simp_isPlaying = false;
eles.remove('fa-pause');
eles.add('fa-play');
}
});
navigator.mediaSession.setActionHandler("previoustrack", () => {
let eles = document.getElementById("simp-previoustrack")
if (simp_a_index !== 0) {
simp_a_index = simp_a_index-1;
eles.disabled = simp_a_index == 0 ? true : false;
}
simp_audio.removeEventListener('timeupdate', simp_initTime);
simp_changeAudio(simp_a_url[simp_a_index]);
simp_setAlbum(simp_a_index);
});
navigator.mediaSession.setActionHandler("nexttrack", () => {
let eles = document.getElementById("simp-nexttrack")
if (simp_a_index !== simp_a_url.length-1) {
simp_a_index = simp_a_index+1;
eles.disabled = simp_a_index == simp_a_url.length-1 ? true : false;
}
simp_audio.removeEventListener('timeupdate', simp_initTime);
simp_changeAudio(simp_a_url[simp_a_index]);
simp_setAlbum(simp_a_index);
});
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
simp_audio.currentTime = simp_audio.currentTime - (details.seekOffset || 10);
});
navigator.mediaSession.setActionHandler('seekforward', (details) => {
simp_audio.currentTime = simp_audio.currentTime + (details.seekOffset || 10);
});
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.fastSeek && 'fastSeek' in simp_audio) {
simp_audio.fastSeek(details.seekTime);
return;
}
simp_audio.currentTime = details.seekTime;
});
navigator.mediaSession.setActionHandler('stop', () => {
let eles = document.getElementById("simp-plause").classList
simp_audio.currentTime = 0;
simp_controls.querySelector('.start-time').innerHTML = '00:00';
if (!simp_isLoaded) simp_loadAudio(simp_a_url[simp_a_index]);
simp_audio.play();
simp_isPlaying = true;
eles.remove('fa-play');
eles.add('fa-pause');
});
}
}
function simp_startScript() {
ap_simp = document.querySelector('#simp');
simp_audio = ap_simp.querySelector('#audio');
simp_album = ap_simp.querySelector('.simp-album');
simp_cover = simp_album.querySelector('.simp-cover');
simp_title = simp_album.querySelector('.simp-title');
simp_artist = simp_album.querySelector('.simp-artist');
simp_controls = ap_simp.querySelector('.simp-controls');
simp_progress = simp_controls.querySelector('.simp-progress');
simp_volume = simp_controls.querySelector('.simp-volume');
simp_v_slider = simp_volume.querySelector('.simp-v-slider');
simp_v_num = simp_v_slider.value; //default volume
simp_others = simp_controls.querySelector('.simp-others');
simp_auto_load = simp_config.auto_load; //auto load audio file
if (simp_config.shide_top) simp_album.parentNode.classList.toggle('simp-hide');
if (simp_config.shide_btm) {
simp_playlist.classList.add('simp-display');
simp_playlist.classList.toggle('simp-hide');
}
if (simp_a_url.length <= 1) {
simp_controls.querySelector('.simp-prev').style.display = 'none';
simp_controls.querySelector('.simp-next').style.display = 'none';
simp_others.querySelector('.simp-plext').style.display = 'none';
simp_others.querySelector('.simp-random').style.display = 'none';
}
// Playlist listeners
simp_source.forEach(function(item, index) {
if (item.classList.contains('simp-active')) simp_a_index = index; //playlist contains '.simp-active'
item.addEventListener('click', function() {
simp_audio.removeEventListener('timeupdate', simp_initTime);
simp_a_index = index;
simp_changeAudio(this.querySelector('.simp-source'));
simp_setAlbum(simp_a_index);
});
});
// FIRST AUDIO LOAD =======
simp_changeAudio(simp_a_url[simp_a_index]);
simp_setAlbum(simp_a_index);
// FIRST AUDIO LOAD =======
// Controls listeners
simp_controls.querySelector('.simp-plauseward').addEventListener('click', function(e) {
const eles = e.target.classList;
if (eles.contains('simp-plause')) {
if (simp_audio.paused) {
if (!simp_isLoaded) simp_loadAudio(simp_a_url[simp_a_index]);
simp_audio.play();
simp_isPlaying = true;
eles.remove('fa-play');
eles.add('fa-pause');
} else {
simp_audio.pause();
simp_isPlaying = false;
eles.remove('fa-pause');
eles.add('fa-play');
}
} else {
if (eles.contains('simp-prev') && simp_a_index != 0) {
simp_a_index = simp_a_index-1;
e.target.disabled = simp_a_index == 0 ? true : false;
} else if (eles.contains('simp-next') && simp_a_index != simp_a_url.length-1) {
simp_a_index = simp_a_index+1;
e.target.disabled = simp_a_index == simp_a_url.length-1 ? true : false;
}
simp_audio.removeEventListener('timeupdate', simp_initTime);
simp_changeAudio(simp_a_url[simp_a_index]);
simp_setAlbum(simp_a_index);
}
});
// Audio volume
simp_volume.addEventListener('click', function(e) {
const eles = e.target.classList;
if (eles.contains('simp-mute')) {
if (eles.contains('fa-volume-up')) {
eles.remove('fa-volume-up');
eles.add('fa-volume-off');
simp_v_slider.value = 0;
} else {
eles.remove('fa-volume-off');
eles.add('fa-volume-up');
simp_v_slider.value = simp_v_num;
}
} else {
simp_v_num = simp_v_slider.value;
if (simp_v_num != 0) {
simp_controls.querySelector('.simp-mute').classList.remove('fa-volume-off');
simp_controls.querySelector('.simp-mute').classList.add('fa-volume-up');
}
}
simp_audio.volume = parseFloat(simp_v_slider.value / 100);
});
// Others
simp_others.addEventListener('click', function(e) {
const eles = e.target.classList;
if (eles.contains('simp-plext')) {
simp_isNext = simp_isNext && !simp_isRandom ? false : true;
if (!simp_isRandom) simp_isRanext = simp_isRanext ? false : true;
eles.contains('simp-active') && !simp_isRandom ? eles.remove('simp-active') : eles.add('simp-active');
} else if (eles.contains('simp-random')) {
simp_isRandom = simp_isRandom ? false : true;
if (simp_isNext && !simp_isRanext) {
simp_isNext = false;
simp_others.querySelector('.simp-plext').classList.remove('simp-active');
} else {
simp_isNext = true;
simp_others.querySelector('.simp-plext').classList.add('simp-active');
}
eles.contains('simp-active') ? eles.remove('simp-active') : eles.add('simp-active');
} else if (eles.contains('simp-shide-top')) {
simp_album.parentNode.classList.toggle('simp-hide');
} else if (eles.contains('simp-shide-bottom')) {
simp_playlist.classList.add('simp-display');
simp_playlist.classList.toggle('simp-hide');
}
});
}
// Start simple player
if (document.querySelector('#simp')) {
var simp_auto_load, simp_audio, simp_album, simp_cover, simp_title, simp_artist, simp_controls, simp_progress, simp_volume, simp_v_slider, simp_v_num, simp_others;
var ap_simp = document.querySelector('#simp');
var simp_playlist = ap_simp.querySelector('.simp-playlist');
var simp_source = simp_playlist.querySelectorAll('li');
var simp_a_url = simp_playlist.querySelectorAll('[data-src]');
var simp_a_index = 0;
var simp_isPlaying = false;
var simp_isNext = true; //auto play
var simp_isRandom = false; //play random
var simp_isRanext = false; //check if before random starts, simp_isNext value is true
var simp_isStream = false; //radio streaming
var simp_isLoaded = false; //audio file has loaded
var simp_config = ap_simp.dataset.config ? JSON.parse(ap_simp.dataset.config) : {
shide_top: false, //show/hide album
shide_btm: false, //show/hide playlist
auto_load: false //auto load audio file
};
let simp_elem = '';
simp_elem += '<audio id="audio" preload><source src="" type="audio/mpeg"></audio>';
simp_elem += '<div class="simp-display"><div class="simp-album w-full flex-wrap"><div class="simp-cover"><i class="fa fa-music fa-5x"></i></div><div class="simp-info"><div class="simp-title">Title</div><div class="simp-artist">Artist</div></div></div></div>';
simp_elem += '<div class="simp-controls flex-wrap flex-align">';
simp_elem += '<div class="simp-plauseward flex flex-align"><button type="button" class="simp-prev fa fa-backward" id="simp-previoustrack" disabled></button><button id="simp-plause" type="button" class="simp-plause fa fa-play" disabled></button><button id="simp-nexttrack" type="button" class="simp-next fa fa-forward" disabled></button></div>';
simp_elem += '<div class="simp-tracker simp-load"><input class="simp-progress" type="range" min="0" max="100" value="0" disabled/><div class="simp-buffer"></div></div>';
simp_elem += '<div class="simp-time flex flex-align"><span class="start-time">00:00</span><span class="simp-slash">&#160;/&#160;</span><span class="end-time">00:00</span></div>';
simp_elem += '<div class="simp-volume flex flex-align"><button type="button" class="simp-mute fa fa-volume-up"></button><input class="simp-v-slider" type="range" min="0" max="100" value="100"/></div>';
simp_elem += '<div class="simp-others flex flex-align"><button type="button" class="simp-plext fa fa-play-circle simp-active" title="Auto Play" ></button><button type="button" class="simp-random fa fa-random" title="Random"></button><div class="simp-shide"><button type="button" class="simp-shide-top fa fa-caret-up" title="Show/Hide Album"></button><button type="button" class="simp-shide-bottom fa fa-caret-down" title="Show/Hide Playlist"></button></div></div>';
simp_elem += '</div>'; //simp-controls
const simp_player = document.createElement('div');
simp_player.classList.add('simp-player');
simp_player.innerHTML = simp_elem;
ap_simp.insertBefore(simp_player, simp_playlist);
simp_startScript();
}

View File

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load static %}
{% block css %}
<link rel="stylesheet" href="{% static 'css/music-player.css' %}">
{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-center">
<div class="simple-audio-player flex-column" id="simp" data-config='{"shide_top":false,"shide_btm":false,"auto_load":true}'>
<div class="simp-playlist">
<ul>
{% for song in song_list %}
<li><span class="simp-source" {% if song.image %}data-cover="{{ song.image.url }}"{% endif %} data-artists="{{ song.artists_names }}" data-albumn="{{ song.album_name }}" data-src="{{ song.file.url }}">{{ song.name }}</span><span class="simp-desc">{{ song.full_props }}</span></li>
{% endfor %}
</ul>
</div>
</div>
</div>
<script src="{% static 'js/music-player.js' %}"></script>
{% endblock content %}

View File

@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block title %}editing post on akarpov{% endblock %} {% block title %}loading music on akarpov{% endblock %}
{% block content %} {% block content %}
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form"> <form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">

View File

@ -60,11 +60,14 @@ def crop_image(image_path: str, length: int = 500):
def user_file_upload_mixin(instance, filename): def user_file_upload_mixin(instance, filename):
"""stores user uploaded files at their folder in media dir""" """stores user uploaded files at their folder in media dir"""
username = "" username = ""
try:
if isinstance(instance, get_user_model()): if isinstance(instance, get_user_model()):
username = instance.username + "/" username = instance.username + "/"
elif hasattr(instance, "user"): elif hasattr(instance, "user"):
username = instance.user.username + "/" username = instance.user.username + "/"
elif hasattr(instance, "creator"): elif hasattr(instance, "creator"):
username = instance.creator.username + "/" username = instance.creator.username + "/"
except AttributeError:
username = "__all"
return os.path.join(f"uploads/{username}", filename) return os.path.join(f"uploads/{username}", filename)

View File

@ -32,6 +32,10 @@
"blog/", "blog/",
include("akarpov.blog.api.urls", namespace="blog"), include("akarpov.blog.api.urls", namespace="blog"),
), ),
path(
"music/",
include("akarpov.music.api.urls", namespace="music"),
),
path( path(
"tools/", "tools/",
include( include(

2534
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -82,7 +82,6 @@ xvfbwrapper = "^0.2.9"
vtk = "^9.2.6" vtk = "^9.2.6"
ffmpeg-python = "^0.2.0" ffmpeg-python = "^0.2.0"
cairosvg = "^2.7.0" cairosvg = "^2.7.0"
textract = "^1.6.5"
spotipy = "2.16.1" spotipy = "2.16.1"
django-robots = "^5.0" django-robots = "^5.0"
django-tables2 = "^2.5.3" django-tables2 = "^2.5.3"
@ -109,6 +108,7 @@ pytest-asyncio = "^0.21.1"
pytest-lambda = "^2.2.0" pytest-lambda = "^2.2.0"
pgvector = "^0.2.2" pgvector = "^0.2.2"
pycld2 = "^0.41" pycld2 = "^0.41"
textract = "^1.6.5"
[build-system] [build-system]