mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-30 18:53:44 +03:00
Compare commits
3 Commits
739ff6a1c0
...
01b529f4a5
Author | SHA1 | Date | |
---|---|---|---|
|
01b529f4a5 | ||
3405b76897 | |||
b02a77ec5e |
|
@ -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
|
||||||
|
|
|
@ -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
17
akarpov/music/api/urls.py
Normal 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()),
|
||||||
|
]
|
58
akarpov/music/api/views.py
Normal file
58
akarpov/music/api/views.py
Normal 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
|
25
akarpov/music/migrations/0007_song_creator.py
Normal file
25
akarpov/music/migrations/0007_song_creator.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
17
akarpov/music/migrations/0008_song_meta.py
Normal file
17
akarpov/music/migrations/0008_song_meta.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}
|
||||||
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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()
|
||||||
|
|
67
akarpov/static/css/music-player.css
Normal file
67
akarpov/static/css/music-player.css
Normal 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;}
|
||||||
|
}
|
415
akarpov/static/js/music-player.js
Normal file
415
akarpov/static/js/music-player.js
Normal 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"> / </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();
|
||||||
|
}
|
19
akarpov/templates/music/player.html
Normal file
19
akarpov/templates/music/player.html
Normal 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 %}
|
|
@ -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">
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
2534
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -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]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user