Compare commits

...

5 Commits

31 changed files with 3413 additions and 1968 deletions

View File

@ -6,3 +6,6 @@ USE_DOCKER=no
EMAIL_HOST=127.0.0.1
EMAIL_PORT=1025
SENTRY_DSN=
EMAIL_PASSWORD=
EMAIL_USER=
EMAIL_USE_SSL=false

View File

@ -1,5 +1,8 @@
from rest_framework import serializers
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):
@ -24,3 +27,19 @@ class RecursiveField(serializers.Serializer):
def to_representation(self, value):
serializer = self.parent.parent.__class__(value, context=self.context)
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 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):
url = serializers.SerializerMethodField(method_name="get_url")
@extend_schema_field(serializers.URLField)
def get_url(self, obj):
return obj.get_absolute_url()
@ -17,6 +21,7 @@ class Meta:
class AlbumSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField(method_name="get_url")
@extend_schema_field(serializers.URLField)
def get_url(self, obj):
return obj.get_absolute_url()
@ -32,7 +37,6 @@ class SongSerializer(serializers.ModelSerializer):
class Meta:
model = Song
fields = [
"id",
"image",
"link",
"length",
@ -42,3 +46,48 @@ class Meta:
"authors",
"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},
}

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

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

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.tools.shortener.models import ShortLinkModel
from akarpov.users.services.history import UserHistoryModel
from akarpov.utils.cache import cache_model_property
class Author(BaseImageModel, ShortLinkModel):
@ -38,10 +39,44 @@ class Song(BaseImageModel, ShortLinkModel):
album = models.ForeignKey(
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):
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):
return self.name
@ -80,8 +115,8 @@ class Meta:
class SongInQue(models.Model):
name = models.CharField(blank=True, max_length=250)
status = models.CharField(null=True, blank=True, max_length=250)
name = models.CharField(blank=True, max_length=500)
status = models.CharField(null=True, blank=True, max_length=500)
error = models.BooleanField(default=False)

View File

@ -1,11 +1,11 @@
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("/"):
process_dir.apply_async(kwargs={"path": address})
list_tracks.apply_async(kwargs={"url": address})
process_dir.apply_async(kwargs={"path": address, "user_id": user_id})
list_tracks.apply_async(kwargs={"url": address, "user_id": user_id})
def load_track_file(file):
process_file.apply_async(kwargs={"path": file})
def load_track_file(file, user_id: int):
process_file.apply_async(kwargs={"path": file, "user_id": user_id})

View File

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

View File

@ -11,18 +11,18 @@
from akarpov.music.services.db import load_track
def load_dir(path: str):
def load_dir(path: str, user_id: int):
path = Path(path)
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):
process_mp3_file(path)
def load_file(path: str, user_id: int):
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)
if "artist" in tag:
author = tag["artist"]
@ -55,6 +55,6 @@ def process_mp3_file(path: str) -> None:
im.save(image_pth)
except UnidentifiedImageError:
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):
os.remove(image_pth)

View File

@ -1,18 +1,13 @@
import os
from pathlib import Path
from random import randint
from django.conf import settings
from django.core.files import File
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 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:
@ -48,88 +43,56 @@ def search_ym(name: str):
return info
def load_file_meta(track: int):
def load_file_meta(track: int, user_id: int):
que = SongInQue.objects.create()
client = login()
track = client.tracks(track)[0] # type: Track
que.name = track.title
que.save()
try:
client = login()
track = client.tracks(track)[0] # type: Track
que.name = track.title
que.save()
try:
if sng := Song.objects.filter(
name=track.title, album__name=track.albums[0].title
):
que.delete()
return sng.first()
except IndexError:
if sng := Song.objects.filter(
name=track.title, album__name=track.albums[0].title
):
que.delete()
return
filename = slugify(f"{track.artists[0].name} - {track.title}")
orig_path = f"{settings.MEDIA_ROOT}/{filename}"
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")
track.download_cover(filename=img_pth)
album = track.albums[0]
# set music meta
tag = MP3(path, ID3=ID3)
tag.tags.add(
APIC(
encoding=3, # 3 is for utf-8
mime="image/png", # image/jpeg or image/png
type=3, # 3 is for the cover image
desc="Cover",
data=open(img_pth, "rb").read(),
)
)
tag.tags.add(TORY(text=str(album.year)))
tag.tags.add(TCON(text=album.genre))
tag.save()
os.remove(img_pth)
tag = EasyID3(path)
tag["title"] = track.title
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)
return sng.first()
except IndexError:
que.delete()
return song
except Exception as e:
que.name = e
que.error = True
que.save()
return
filename = slugify(f"{track.artists[0].name} - {track.title}")
orig_path = f"{settings.MEDIA_ROOT}/{filename}.mp3"
album = track.albums[0]
track.download(filename=orig_path, codec="mp3")
img_pth = str(settings.MEDIA_ROOT + f"/_{str(randint(10000, 99999))}.png")
track.download_cover(filename=img_pth)
song = load_track(
orig_path,
img_pth,
user_id,
[x.name for x in track.artists],
album.title,
track.title,
release=album.release_date,
genre=album.genre,
)
if os.path.exists(orig_path):
os.remove(orig_path)
if os.path.exists(img_pth):
os.remove(img_pth)
return str(song)
def load_playlist(link: str):
def load_playlist(link: str, user_id: int):
author = link.split("/")[4]
playlist_id = link.split("/")[-1]
client = login()
playlist = client.users_playlists(int(playlist_id), author) # type: Playlist
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
def download_from_youtube_link(link: str) -> Song:
def download_from_youtube_link(link: str, user_id: int) -> Song:
song = None
with YoutubeDL(ydl_opts) as ydl:
@ -118,6 +118,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track(
chapter_path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
chapters[i][2],
@ -127,6 +128,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track(
chapter_path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
chapters[i][2],
@ -152,6 +154,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track(
path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
title,
@ -161,6 +164,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track(
path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
title,

View File

@ -11,44 +11,44 @@
@shared_task
def list_tracks(url):
def list_tracks(url, user_id):
if "music.yandex.ru" in url:
yandex.load_playlist(url)
yandex.load_playlist(url, user_id)
elif "channel" in url or "/c/" in url:
p = Channel(url)
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:
p = Playlist(url)
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:
process_yb.apply_async(kwargs={"url": url})
process_yb.apply_async(kwargs={"url": url, "user_id": user_id})
return url
@shared_task(max_retries=5)
def process_yb(url):
youtube.download_from_youtube_link(url)
def process_yb(url, user_id):
youtube.download_from_youtube_link(url, user_id)
return url
@shared_task
def process_dir(path):
load_dir(path)
def process_dir(path, user_id):
load_dir(path, user_id)
return path
@shared_task
def process_file(path):
load_file(path)
def process_file(path, user_id):
load_file(path, user_id)
return path
@shared_task
def load_ym_file_meta(track):
return yandex.load_file_meta(track)
def load_ym_file_meta(track, user_id):
return yandex.load_file_meta(track, user_id)
@shared_task()

View File

@ -12,4 +12,5 @@
path("author/<str:slug>", views.author_view, name="author"),
path("playlist/<str:slug>", views.playlist_view, name="playlist"),
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 ""
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)
@ -73,7 +73,7 @@ def get_success_url(self):
def form_valid(self, form):
for file in form.cleaned_data["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)
@ -86,3 +86,14 @@ class MainRadioView(generic.TemplateView):
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 crispy_forms_tags %}
{% block title %}editing post on akarpov{% endblock %}
{% block title %}loading music on akarpov{% endblock %}
{% block content %}
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">

View File

@ -0,0 +1,6 @@
from rest_framework import serializers
class URLPathSerializer(serializers.Serializer):
path = serializers.URLField()
kwargs = serializers.DictField(help_text="{'slug': 'str', 'pk': 'int'}")

View File

@ -0,0 +1,68 @@
from functools import lru_cache
from config import urls as urls_conf
urls = None
def get_urls(urllist, name="") -> (list, list):
res = []
res_short = []
for entry in urllist:
if hasattr(entry, "url_patterns"):
if entry.namespace != "admin":
rres, rres_short = get_urls(
entry.url_patterns,
name + entry.namespace + ":" if entry.namespace else name,
)
res += rres
res_short += rres_short
else:
res.append(
(
name + entry.pattern.name if entry.pattern.name else "",
str(entry.pattern),
)
)
res_short.append(
(
entry.pattern.name,
str(entry.pattern),
)
)
return res, res_short
@lru_cache
def urlpattern_to_js(pattern: str) -> (str, dict):
if pattern.startswith("^"):
return pattern
res = ""
kwargs = {}
for p in pattern.split("<"):
if ">" in p:
rec = ""
pn = p.split(">")
k = pn[0].split(":")
if len(k) == 1:
rec = "{" + k[0] + "}"
kwargs[k[0]] = "any"
elif len(k) == 2:
rec = "{" + k[1] + "}"
kwargs[k[1]] = k[0]
res += rec + pn[-1]
else:
res += p
return res, kwargs
def get_api_path_by_url(name: str) -> tuple[str, dict] | None:
global urls
if not urls:
urls, urls_short = get_urls(urls_conf.urlpatterns)
urls = dict(urls_short) | dict(urls)
if name in urls:
return urlpattern_to_js(urls[name])
return None

View File

@ -1,7 +1,10 @@
from django.urls import include, path
from akarpov.tools.api.views import RetrieveAPIUrlAPIView
app_name = "tools"
urlpatterns = [
path("<str:path>", RetrieveAPIUrlAPIView.as_view(), name="path"),
path("qr/", include("akarpov.tools.qr.api.urls", namespace="qr")),
]

View File

@ -0,0 +1,18 @@
from rest_framework import generics
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from akarpov.tools.api.serializers import URLPathSerializer
from akarpov.tools.api.services import get_api_path_by_url
class RetrieveAPIUrlAPIView(generics.GenericAPIView):
serializer_class = URLPathSerializer
permission_classes = [AllowAny]
def get(self, request, *args, **kwargs):
path, k_args = get_api_path_by_url(self.kwargs["path"])
if not path:
raise NotFound
return Response(data={"path": path, "kwargs": k_args})

View File

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

View File

@ -11,10 +11,6 @@ entryPoints:
entryPoint:
to: web-secure
web-secure:
# https
address: ":443"
flower:
address: ":5555"
@ -29,27 +25,6 @@ certificatesResolvers:
entryPoint: web
http:
routers:
web-secure-router:
rule: "Host(`akarpov.ru`) || Host(`www.akarpov.ru`)"
entryPoints:
- web-secure
middlewares:
- csrf
service: django
tls:
# https://docs.traefik.io/master/routing/routers/#certresolver
certResolver: letsencrypt
flower-secure-router:
rule: "Host(`akarpov.ru`)"
entryPoints:
- flower
service: flower
tls:
# https://docs.traefik.io/master/routing/routers/#certresolver
certResolver: letsencrypt
middlewares:
csrf:
# https://docs.traefik.io/master/middlewares/headers/#hostsproxyheaders

View File

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

View File

@ -307,6 +307,18 @@
)
# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout
EMAIL_TIMEOUT = 5
EMAIL_HOST_PASSWORD = env(
"EMAIL_PASSWORD",
default="",
)
EMAIL_HOST_USER = env(
"EMAIL_USER",
default="",
)
EMAIL_USE_SSL = env(
"EMAIL_USE_SSL",
default=False,
)
# ADMIN
# ------------------------------------------------------------------------------

4245
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"
ffmpeg-python = "^0.2.0"
cairosvg = "^2.7.0"
textract = "^1.6.5"
spotipy = "2.16.1"
django-robots = "^5.0"
django-tables2 = "^2.5.3"
@ -109,6 +108,7 @@ pytest-asyncio = "^0.21.1"
pytest-lambda = "^2.2.0"
pgvector = "^0.2.2"
pycld2 = "^0.41"
textract = "^1.6.5"
[build-system]