From abc307bfadab93845b1b845724b3d519f52c34f8 Mon Sep 17 00:00:00 2001
From: Dmitry Tretyakov <76806170+tretdm@users.noreply.github.com>
Date: Sun, 29 Oct 2023 21:41:36 +0700
Subject: [PATCH 01/21] Update PHP 8 testing status

---
 README_RU.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README_RU.md b/README_RU.md
index cc4f672f..6da17408 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -30,7 +30,7 @@ _[English](README.md)_
 
 1. Установите PHP 7.4, веб-сервер, Composer, Node.js, Yarn и [Chandler](https://github.com/openvk/chandler)
 
-* PHP 8 еще **не** тестировался, поэтому не стоит ожидать, что он будет работать (UPD: он не работает).
+* PHP 8 is still being tested; performance on this version of PHP is not yet guaranteed.
 
 2. Установите MySQL-совместимую базу данных.
 

From 46f7e7ceebc11b009f11bed3a4b2df2cb097064e Mon Sep 17 00:00:00 2001
From: Dmitry Tretyakov <76806170+tretdm@users.noreply.github.com>
Date: Sun, 29 Oct 2023 21:42:55 +0700
Subject: [PATCH 02/21] Update PHP 8 testing status

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 152e05bf..934ff0b7 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ If you want, you can add your instance to the list above so that people can regi
 
 1. Install PHP 7.4, web-server, Composer, Node.js, Yarn and [Chandler](https://github.com/openvk/chandler)
 
-* PHP 8.1 is supported too, however it was not tested carefully, so be aware.
+* PHP 8 is still being tested; the functionality of the engine on this version of PHP is not yet guaranteed.
 
 2. Install MySQL-compatible database.
 

From 1a17a245c5dab15bf04e0b7bd4950f33fd580ed5 Mon Sep 17 00:00:00 2001
From: Dmitry Tretyakov <76806170+tretdm@users.noreply.github.com>
Date: Sun, 29 Oct 2023 21:55:02 +0700
Subject: [PATCH 03/21] i forgor momento

---
 README_RU.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README_RU.md b/README_RU.md
index 6da17408..7de91c39 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -30,7 +30,7 @@ _[English](README.md)_
 
 1. Установите PHP 7.4, веб-сервер, Composer, Node.js, Yarn и [Chandler](https://github.com/openvk/chandler)
 
-* PHP 8 is still being tested; performance on this version of PHP is not yet guaranteed.
+* PHP 8 пока ещё тестируется, работоспособность движка на этой версии PHP пока не гарантируется.
 
 2. Установите MySQL-совместимую базу данных.
 

From 9a4309ae01588e9bc2e9926bd538016f32e4bf76 Mon Sep 17 00:00:00 2001
From: Vladimir Barinov <veselcraft@icloud.com>
Date: Tue, 31 Oct 2023 00:13:36 +0300
Subject: [PATCH 04/21] =?UTF-8?q?=D1=89=D0=B0=D1=81=20=D0=B4=D0=BE=D0=BB?=
 =?UTF-8?q?=D0=B6=D0=B5=D0=BD=20=D0=BD=D0=BE=D1=83=D1=81=D0=BF=D0=B0=D0=BC?=
 =?UTF-8?q?=20=D0=B7=D0=B0=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 Web/Models/Entities/PasswordReset.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/Web/Models/Entities/PasswordReset.php b/Web/Models/Entities/PasswordReset.php
index cf0e73ea..372c63f8 100644
--- a/Web/Models/Entities/PasswordReset.php
+++ b/Web/Models/Entities/PasswordReset.php
@@ -54,11 +54,11 @@ class PasswordReset extends RowModel
         }
     }
     
-    function save(): void
+    function save(?bool $log = false): void
     {
         $this->stateChanges("key", base64_encode(openssl_random_pseudo_bytes(46)));
         $this->stateChanges("timestamp", time());
         
-        parent::save();
+        parent::save($log);
     }
 }

From fd11dfcdd919854f2d5c4834413a0a1a81b01bbe Mon Sep 17 00:00:00 2001
From: Vladimir Barinov <veselcraft@icloud.com>
Date: Tue, 31 Oct 2023 00:14:18 +0300
Subject: [PATCH 05/21] =?UTF-8?q?=D0=B2=D1=8B=20=D0=B4=D0=BE=D0=BB=D0=B1?=
 =?UTF-8?q?=D0=BE=D1=91=D0=B1=D1=8B=20=D0=B1=D0=BB=D1=8F=D1=82=D1=8C=20?=
 =?UTF-8?q?=D0=B2=D1=8B=20=D0=BD=D0=B0=D1=85=D1=83=D1=8F=20=D0=BE=D1=81?=
 =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=B8=D0=BB=D0=B8=20=D0=B4=D0=B5=D0=B1=D0=B0?=
 =?UTF-8?q?=D0=B3=20=D1=84=D0=B8=D1=87=D1=83=20=D0=B2=20=D1=80=D0=B5=D0=BB?=
 =?UTF-8?q?=D0=B8=D0=B7=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 Web/Models/Repositories/Messages.php | 1 -
 1 file changed, 1 deletion(-)

diff --git a/Web/Models/Repositories/Messages.php b/Web/Models/Repositories/Messages.php
index 4c870806..538338ed 100644
--- a/Web/Models/Repositories/Messages.php
+++ b/Web/Models/Repositories/Messages.php
@@ -52,7 +52,6 @@ class Messages
         $query = file_get_contents(__DIR__ . "/../sql/get-correspondencies-count.tsql");
         DatabaseConnection::i()->getConnection()->query(file_get_contents(__DIR__ . "/../sql/mysql-msg-fix.tsql"));
         $count = DatabaseConnection::i()->getConnection()->query($query, $id, $class, $id, $class)->fetch()->cnt;
-        bdump($count);
         return $count;
     }
 }

From a2473c68fe8522a8fc39a95ab01c1e66eb8e6c2e Mon Sep 17 00:00:00 2001
From: veselcraft <veselcraft@icloud.com>
Date: Wed, 1 Nov 2023 14:52:24 +0300
Subject: [PATCH 06/21] VKAPI: PHP 8.2 fixes

---
 Web/Presenters/VKAPIPresenter.php | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/Web/Presenters/VKAPIPresenter.php b/Web/Presenters/VKAPIPresenter.php
index 431379c5..963c9ccc 100644
--- a/Web/Presenters/VKAPIPresenter.php
+++ b/Web/Presenters/VKAPIPresenter.php
@@ -233,8 +233,13 @@ final class VKAPIPresenter extends OpenVKPresenter
                     $this->badMethodCall($object, $method, $parameter->getName());
             }
             
-            settype($val, $parameter->getType()->getName());
-            $params[] = $val;
+            try {
+                settype($val, $parameter->getType()->getName());
+                $params[] = $val;
+            } catch (\Throwable $e) {
+                // Just ignore the exception, since
+                // some args are intended for internal use
+            }
         }
         
         define("VKAPI_DECL_VER", $this->requestParam("v") ?? "4.100", false);

From 49d62543ba2374a61948176fc36392627e9881df Mon Sep 17 00:00:00 2001
From: lalka2018 <99399973+lalka2016@users.noreply.github.com>
Date: Sun, 5 Nov 2023 15:19:06 +0300
Subject: [PATCH 07/21] Fix 500 in photos.get

when album does not exists
---
 VKAPI/Handlers/Photos.php          | 13 +++++--------
 Web/Models/Repositories/Albums.php |  4 ++--
 2 files changed, 7 insertions(+), 10 deletions(-)

diff --git a/VKAPI/Handlers/Photos.php b/VKAPI/Handlers/Photos.php
index e3e9abac..bb1a22f0 100644
--- a/VKAPI/Handlers/Photos.php
+++ b/VKAPI/Handlers/Photos.php
@@ -432,13 +432,11 @@ final class Photos extends VKAPIRequestHandler
         if(empty($photo_ids)) {
             $album = (new Albums)->getAlbumByOwnerAndId($owner_id, $album_id);
 
-            if(!$album->getOwner()->getPrivacyPermission('photos.read', $this->getUser())) {
-                $this->fail(21, "This user chose to hide his albums.");
-            }
-
-            if(!$album || $album->isDeleted()) {
+            if(!$album || $album->isDeleted())
                 $this->fail(21, "Invalid album");
-            }
+
+            if(!$album->getOwner()->getPrivacyPermission('photos.read', $this->getUser()))
+                $this->fail(21, "This user chose to hide his albums.");
             
             $photos = array_slice(iterator_to_array($album->getPhotos(1, $count + $offset)), $offset);
             $res["count"] = sizeof($photos);
@@ -456,8 +454,7 @@ final class Photos extends VKAPIRequestHandler
                 "items" => []
             ];
 
-            foreach($photos as $photo)
-            {
+            foreach($photos as $photo) {
                 $id = explode("_", $photo);
     
                 $phot = (new PhotosRepo)->getByOwnerAndVID((int)$id[0], (int)$id[1]);
diff --git a/Web/Models/Repositories/Albums.php b/Web/Models/Repositories/Albums.php
index b38ee462..f99848c4 100644
--- a/Web/Models/Repositories/Albums.php
+++ b/Web/Models/Repositories/Albums.php
@@ -130,7 +130,7 @@ class Albums
             "owner" => $owner,
             "id"    => $id
         ])->fetch();
-        
-        return new Album($album);
+
+        return $album ? new Album($album) : NULL;
     }
 }

From c9665ac77d7a7601e18e602e0346af0d4405bf31 Mon Sep 17 00:00:00 2001
From: lalka2018 <99399973+lalka2016@users.noreply.github.com>
Date: Tue, 7 Nov 2023 20:32:04 +0300
Subject: [PATCH 08/21] Resolves #1017

---
 Web/Models/Entities/User.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php
index aaf00ec9..dc227039 100644
--- a/Web/Models/Entities/User.php
+++ b/Web/Models/Entities/User.php
@@ -720,8 +720,8 @@ class User extends RowModel
 
         for($i = 0; $i < 10 - $this->get2faBackupCodeCount(); $i++) {
             $codes[] = [
-                owner => $this->getId(),
-                code => random_int(10000000, 99999999)
+                "owner" => $this->getId(),
+                "code" => random_int(10000000, 99999999)
             ];
         }
 

From 9d7a465d0d993319bf21b4af941d07b1228f9ef4 Mon Sep 17 00:00:00 2001
From: celestora <kitsuruko@gmail.com>
Date: Sat, 11 Nov 2023 23:41:07 +0200
Subject: [PATCH 09/21] Music, finally! (#512)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Add audio upload feature

* Add audio embed thing

* Move bullet.gif to ovk

* Draft some music API methods

* Add support for base64 ids to Audios.getById

* Disallow having more than 65k audios in playlist

* Add playlist model

* Draft some playlist-related API methods

* Fix behabiour of album-related methods

Generators f***** me in le a**

* Add IDv3 autofill

* Add sql dumps

i forgor to upload it xdddd

* Add playlists sql

* Fix audio upload not working on Windows 11 because Windows is the worst operating system which doesn't work properly under any circumstances

* Fix cocksex in audio.get

yes

* Интерфейсы

* Interface updade

* Update en.strings

* Add audio queue

* Make repeat button work

* Some improvements to audio queue

* Фгвшщ йгугу шьзкщмуьутеы

* Make shuffle and "наушники" buttons work, add f...

avicons when playing audio, save some values (like volume and last played track) to localstorage, add ability to toggle time type in player, fix uploading audios with cover (maybe) and add dragndrop to upload page

* Add funny tip with time when hover track div

* Add something

* Add audios picker & move track in smal player вниз

* Summary (required)

Description

* [WIP] Add calls, stories and clips.

Изменены фавиконки (поменьше стали)
У миниплеера ползунок теперь в стиле bsdn и большого плеера, добавлен ползунок громкости
Добавлена кнопка добавления аудио в группу (у миниплеера)
Если вы смотрите аудио группы, которой можете управлять, появляется кнопка "удалить аудио из группы"
Снизу плейлиста в списке теперь показывается автор.
При прикреплении аудиозаписей к посту теперь есть поиск "по композиции" и "по исполнителю"
Добавил explicit.svg, который я забыл добавить в предыдущем коммите.
Вкладочки немного переделаны
При наведении на кнопки "трек вперёд" или "трек назад" показывается название предыдущего или следующего трека соответственно

* 1 new commit to master: [WIP]: Add audios

- Теперь группа может разрешать загружать всем треки в неё
- Теперь треки загружаются на сервер ajax'ом, и так можно очень много аудио загружать
- Вёрстка списка плейлистов изменена, теперь она на гридах
- Немного изменено апи, теперь метод editAlbum сохраняет новую информацию ee объект плейлистов теперь возвращают реальное время
- Удалены лишние пути из routes.yml
- При переключении страниц теперь если на текущей странице есть играющий трек, он нормально подсвечивается
- Из init-db.sql удалены таблицы аудиозаписей
- В Groups.getSettings и groups.edit теперь есть информация о аудиозаписях

* (смешное название коммита)

- Теперь на странице пользователя/группы показываются три случайные песни, а не первые три как раньше
- Теперь пробел на странице аудио не перемещает вас в низ страницы
- Оптимизирован мини-плеер, теперь он инициализируется при любом нажатии на него, а не при наведении
- Теперь при завершении проигрывания трека в мини-плеере он ищет другой трек рядом, и если находит то воспроизводит. Будет удобно для постов с подборками треков
- Поиск теперь показывает 14 результатов
- Теперь при возникновении ошибки загрузки аудио она нормально отображается
- Вместе с плеером на странице с аудиозаписями теперь двигаются и вкладки
- Добавление аудио в группу по идее должно нормально работать

* Implement playlists listens

- У плейлистов теперь есть прослушивания в общем.
- Прослушивания у большого плеера теперь засчитываются, если трек был дослушан до конца
- В объекте плейлистов теперь возвращается listens и cover_url
- Получение плееров через /audios/context переписано, повторяющийся код удалён, правда сильно количество строк сократить не получилось
- Теперь цвета плеера темнее, а иконка проигрывания изменена
- Теперь, если очередь из треков кончилась, то плеер перенаправляет вас в начало очереди.

* php 8.2 fixxxxxxxxxxxxxxxxxxxxxxx

* Implement audiostatuses

Добавлены аудиостатусы (у пользователей), блок с друзьями, слушающих музыку на странице аудиозаписей, объект status_audio в users.get, улучшены настройки приватности и ещё что-то

* ?

- Переделан метод в классе user для получения друзей с проигрываемыми песнями. Теперь среди них могут появляться и группы (хз стоит ли оставлять это или нет). Так же больше не показываются удалённые пользователи
- Трек у плеера теперь двигается немного плавнее. Ещё теперь нету смешных багов с подсказкой времени, когда можно было увести её за экран или  промотать дальше трека. Переключить повторение трека теперь можно нажатием кнопки R.
- Длинное название трека больше не сносит время
- Наверное, теперь аудиозаписи нормально отображаются в темах midnight и modern
- Аудиозаписи больше не крашаются, если пользователь неавторизован.
- Немного переделан миниплеер.
- В миниплеере теперь громкость берётся из локалсторейджа.
- Улучшено редактирование аудиозаписей. Теперь данные в дата атрибуты нормально сохраняются, а так же слова песни и метка "explicit" меняются
- Удалён css, оставшийся ещё от public technical preview 1, а так же путь /audios{num}
- При наведении на трек теперь пропадает время, и на его месте появляются кнопки
- Стандартная аватарка в midnight теперь инвертируется
- В админке в редактировании аудио теперь показывается дата редактирования, дата создания, длина и оригинальный файл аудио. Так же на странице редактирования больше нет вылетов, если вы задали несуществующий аккаунт

* !

- Добавлены строки для мобильной темы
- Добавлено предупреждение перед полным удалением плейлиста
- Нажатие кнопки M = нажатие кнопки наушников
- В классе апи Audio поставлены willExecuteWriteAction, ещё теперь нельзя получить число аудиозаписей у пользователей, которые их закрыли. Ещё теперь нельзя получать uploaded_only аудиозаписи у тех ну вы поняли короче.
- При наведении на длинное название песни оно теперь показывается полностью
- Надо ещё что-то сюда написать, так что: При редактировании аудиозаписи название окна теперь не "Редактировать", а "Редактировать аудиозапись", а вместо кнопки OK кнопка "Сохранить"

* .

- Добавлен тур по аудиозаписям, но пока без скриншотов.
- "Мои Аудиозаписи" в меню теперь располагаются под Моими Видеозаписями для канона
- В настройках приватности "кто может видеть мои аудиозаписи" теперь располагаются под "кто может видеть мои видеозаписи"
- В настройках внешнего вида мои аудиозаписи тоже под видео
- Изменён <title> на странице аудиозаписей. Теперь показывается "Аудиозаписи" + имя пользователя в родительном падеже. А если это группа, то "Аудиозаписи группы". То же самое с плейлистами
- Исправлены ссылка в ссылке на странице с плейлистами
- При наведении на название песни больше не сносится иконка explicit
- Добавлена максимальная длина названия и описания плейлиста при редактировании.

* М

- Долокализована админка (точно помню, что уже делал это, но ладно)
- Удалён лишний пункт "audios" в getLeftMenuItemStatus (реально)
- Если. У плеера есть параметр "hideButtons", то при наведении на него не пропадает время.
- На странице редактирования/создания плейлиста если у песни длинное название, то оно да похуй короче. Ну в общем лучше стало
- Там где нужно, добавлена строка в конце файла
- Возвращена строка "photo" в английской локали (я её случайно удалил :+1: )

* у

- У изъятых аудиозаписей больше не показывается кнопка "добавить в группу". Так же при нажатии на кнопку удаления из коллекции окно не всплывает.
- "Удаление аудио из группы" тоже лучше работать стало с изъятыми аудио.

* з

- В пикере аудиозаписей "more..." заменено на "показать больше аудиозаписей"
- Если включен режим показа оставшегося времени, то при окончании песни больше не показывается "--1:--1"
- В пикере аудиозаписей, если у вас нет аудиозаписей и вы ничего не искали, показывается "Вы ещё не добавляли аудиозаписей"
- <hr>'ы стали серыми
- Добавлены title'ы у кнопок в большом плеере
- Проставлены alt'ы у плейлистов

* Musique: linux saport)

назар хуйню релизнул кста, плейерс клаб два не слушайте не рекомендую

* Update and rename gamma-00000-disco.sql to 00041-music.sql

* Update 00041-music.sql

---------

Co-authored-by: Ilya Prokopenko <dsrev@protonmail.com>
Co-authored-by: n1rwana <aydashkin@vk.com>
Co-authored-by: lalka2018 <99399973+lalka2016@users.noreply.github.com>
Co-authored-by: veselcraft <veselcraft@icloud.com>
Co-authored-by: DeathPleiad <43928323+Parad1seF0x@users.noreply.github.com>
---
 VKAPI/Handlers/Audio.php                      |  794 ++++++++-
 VKAPI/Handlers/Groups.php                     |   32 +-
 VKAPI/Handlers/Status.php                     |   24 +-
 VKAPI/Handlers/Users.php                      |    6 +
 VKAPI/Handlers/Wall.php                       |   25 +
 Web/Models/Entities/Audio.php                 |  469 ++++++
 Web/Models/Entities/Club.php                  |   38 +-
 Web/Models/Entities/MediaCollection.php       |   72 +-
 Web/Models/Entities/Playlist.php              |  256 +++
 Web/Models/Entities/Report.php                |    3 +-
 Web/Models/Entities/Traits/TAudioStatuses.php |   38 +
 Web/Models/Entities/Traits/TOwnable.php       |    6 +
 Web/Models/Entities/User.php                  |   55 +-
 Web/Models/Entities/Video.php                 |    2 +-
 Web/Models/Repositories/Audios.php            |  296 ++++
 Web/Models/shell/processAudio.ps1             |   39 +
 Web/Models/shell/processAudio.sh              |   35 +
 Web/Presenters/AdminPresenter.php             |   77 +-
 Web/Presenters/AudioPresenter.php             |  696 ++++++++
 Web/Presenters/BlobPresenter.php              |    4 +-
 Web/Presenters/CommentPresenter.php           |   26 +-
 Web/Presenters/GroupPresenter.php             |    5 +-
 Web/Presenters/ReportPresenter.php            |    4 +-
 Web/Presenters/SearchPresenter.php            |   31 +-
 Web/Presenters/UserPresenter.php              |   11 +-
 Web/Presenters/WallPresenter.php              |   26 +-
 Web/Presenters/templates/@layout.xml          |   14 +-
 Web/Presenters/templates/About/Tour.xml       |   24 +-
 Web/Presenters/templates/Admin/@layout.xml    |    3 +
 Web/Presenters/templates/Admin/EditMusic.xml  |   81 +
 .../templates/Admin/EditPlaylist.xml          |   54 +
 Web/Presenters/templates/Admin/Music.xml      |  135 ++
 .../templates/Audio/ApiGetContext.xml         |    7 +
 .../templates/Audio/EditPlaylist.xml          |   95 ++
 Web/Presenters/templates/Audio/Embed.xml      |   20 +
 Web/Presenters/templates/Audio/List.xml       |  126 ++
 .../templates/Audio/NewPlaylist.xml           |  109 ++
 Web/Presenters/templates/Audio/Playlist.xml   |   81 +
 Web/Presenters/templates/Audio/Upload.xml     |  212 +++
 Web/Presenters/templates/Audio/bigplayer.xml  |   56 +
 Web/Presenters/templates/Audio/player.xml     |   69 +
 Web/Presenters/templates/Audio/tabs.xml       |   40 +
 Web/Presenters/templates/Group/Edit.xml       |    9 +
 Web/Presenters/templates/Group/View.xml       |   19 +
 .../templates/Photos/UploadPhoto.xml          |    2 +-
 Web/Presenters/templates/Report/Tabs.xml      |    3 +
 .../templates/Report/ViewContent.xml          |    2 +
 Web/Presenters/templates/Search/Index.xml     |   42 +-
 Web/Presenters/templates/User/Edit.xml        |    7 +
 Web/Presenters/templates/User/Settings.xml    |   24 +
 Web/Presenters/templates/User/View.xml        |   46 +-
 Web/Presenters/templates/_includeCSS.xml      |    4 +-
 .../templates/components/attachment.xml       |    4 +
 .../templates/components/textArea.xml         |    7 +
 Web/di.yml                                    |    2 +
 Web/routes.yml                                |   52 +-
 Web/static/css/audios.css                     |  661 ++++++++
 Web/static/css/main.css                       |  229 +--
 Web/static/img/audio.png                      |  Bin 0 -> 560 bytes
 Web/static/img/audios_controls.png            |  Bin 0 -> 3706 bytes
 Web/static/img/bullet.gif                     |  Bin 0 -> 53 bytes
 Web/static/img/explicit.svg                   |    4 +
 Web/static/img/favicons/favicon24_paused.png  |  Bin 0 -> 932 bytes
 Web/static/img/favicons/favicon24_playing.png |  Bin 0 -> 1081 bytes
 Web/static/img/play_buttons.gif               |  Bin 0 -> 103 bytes
 Web/static/img/progressbar.gif                |  Bin 0 -> 1018 bytes
 Web/static/img/song.jpg                       |  Bin 0 -> 2481 bytes
 Web/static/img/tour/audios.png                |  Bin 0 -> 6234 bytes
 Web/static/img/tour/audios_playlists.png      |  Bin 0 -> 6234 bytes
 Web/static/img/tour/audios_search.png         |  Bin 0 -> 6234 bytes
 Web/static/img/tour/audios_upload.png         |  Bin 0 -> 6234 bytes
 Web/static/js/al_music.js                     | 1433 +++++++++++++++++
 Web/static/js/al_playlists.js                 |  113 ++
 Web/static/js/al_wall.js                      |   24 +-
 Web/static/js/package.json                    |    2 +
 Web/static/js/yarn.lock                       |   67 +
 bootstrap.php                                 |   27 +
 composer.json                                 |    1 +
 install/init-static-db.sql                    |   21 -
 install/sqls/00041-music.sql                  |  100 ++
 locales/en.strings                            |  165 +-
 locales/ru.strings                            |  165 +-
 openvk-example.yml                            |    2 +
 themepacks/midnight/stylesheet.css            |  127 +-
 themepacks/openvk_modern/stylesheet.css       |   47 +-
 85 files changed, 7333 insertions(+), 274 deletions(-)
 create mode 100644 Web/Models/Entities/Audio.php
 create mode 100644 Web/Models/Entities/Playlist.php
 create mode 100644 Web/Models/Entities/Traits/TAudioStatuses.php
 create mode 100644 Web/Models/Repositories/Audios.php
 create mode 100644 Web/Models/shell/processAudio.ps1
 create mode 100644 Web/Models/shell/processAudio.sh
 create mode 100644 Web/Presenters/AudioPresenter.php
 create mode 100644 Web/Presenters/templates/Admin/EditMusic.xml
 create mode 100644 Web/Presenters/templates/Admin/EditPlaylist.xml
 create mode 100644 Web/Presenters/templates/Admin/Music.xml
 create mode 100644 Web/Presenters/templates/Audio/ApiGetContext.xml
 create mode 100644 Web/Presenters/templates/Audio/EditPlaylist.xml
 create mode 100644 Web/Presenters/templates/Audio/Embed.xml
 create mode 100644 Web/Presenters/templates/Audio/List.xml
 create mode 100644 Web/Presenters/templates/Audio/NewPlaylist.xml
 create mode 100644 Web/Presenters/templates/Audio/Playlist.xml
 create mode 100644 Web/Presenters/templates/Audio/Upload.xml
 create mode 100644 Web/Presenters/templates/Audio/bigplayer.xml
 create mode 100644 Web/Presenters/templates/Audio/player.xml
 create mode 100644 Web/Presenters/templates/Audio/tabs.xml
 create mode 100644 Web/static/css/audios.css
 create mode 100644 Web/static/img/audio.png
 create mode 100644 Web/static/img/audios_controls.png
 create mode 100644 Web/static/img/bullet.gif
 create mode 100644 Web/static/img/explicit.svg
 create mode 100644 Web/static/img/favicons/favicon24_paused.png
 create mode 100644 Web/static/img/favicons/favicon24_playing.png
 create mode 100644 Web/static/img/play_buttons.gif
 create mode 100644 Web/static/img/progressbar.gif
 create mode 100644 Web/static/img/song.jpg
 create mode 100644 Web/static/img/tour/audios.png
 create mode 100644 Web/static/img/tour/audios_playlists.png
 create mode 100644 Web/static/img/tour/audios_search.png
 create mode 100644 Web/static/img/tour/audios_upload.png
 create mode 100644 Web/static/js/al_music.js
 create mode 100644 Web/static/js/al_playlists.js
 create mode 100644 install/sqls/00041-music.sql

diff --git a/VKAPI/Handlers/Audio.php b/VKAPI/Handlers/Audio.php
index 3fa68e72..413a2a3a 100644
--- a/VKAPI/Handlers/Audio.php
+++ b/VKAPI/Handlers/Audio.php
@@ -1,22 +1,788 @@
 <?php declare(strict_types=1);
 namespace openvk\VKAPI\Handlers;
+use Chandler\Database\DatabaseConnection;
+use openvk\Web\Models\Entities\Audio as AEntity;
+use openvk\Web\Models\Entities\Playlist;
+use openvk\Web\Models\Repositories\Audios;
+use openvk\Web\Models\Repositories\Clubs;
+use openvk\Web\Models\Repositories\Util\EntityStream;
 
 final class Audio extends VKAPIRequestHandler
 {
-	function get(): object
+    private function toSafeAudioStruct(?AEntity $audio, ?string $hash = NULL, bool $need_user = false): object
+    {
+        if(!$audio)
+            $this->fail(0404, "Audio not found");
+        else if(!$audio->canBeViewedBy($this->getUser()))
+            $this->fail(201, "Access denied to audio(" . $audio->getPrettyId() . ")");
+
+        # рофлан ебало
+        $privApi  = $hash && $GLOBALS["csrfCheck"];
+        $audioObj = $audio->toVkApiStruct($this->getUser());
+        if(!$privApi) {
+            $audioObj->manifest = false;
+            $audioObj->keys     = false;
+        }
+
+        if($need_user) {
+            $user = (new \openvk\Web\Models\Repositories\Users)->get($audio->getOwner()->getId());
+            $audioObj->user = (object) [
+                "id"       => $user->getId(),
+                "photo"    => $user->getAvatarUrl(),
+                "name"     => $user->getCanonicalName(),
+                "name_gen" => $user->getCanonicalName(),
+            ];
+        }
+
+        return $audioObj;
+    }
+
+    private function streamToResponse(EntityStream $es, int $offset, int $count, ?string $hash = NULL): object
+    {
+        $items = [];
+        foreach($es->offsetLimit($offset, $count) as $audio) {
+            $items[] = $this->toSafeAudioStruct($audio, $hash);
+        }
+
+        return (object) [
+            "count" => sizeof($items),
+            "items" => $items,
+        ];
+    }
+
+    private function validateGenre(?string& $genre_str, ?int $genre_id): void
+    {
+        if(!is_null($genre_str)) {
+            if(!in_array($genre_str, AEntity::genres))
+                $this->fail(8, "Invalid genre_str");
+        } else if(!is_null($genre_id)) {
+            $genre_str = array_flip(AEntity::vkGenres)[$genre_id] ?? NULL;
+            if(!$genre_str)
+                $this->fail(8, "Invalid genre ID $genre_id");
+        }
+    }
+
+    private function audioFromAnyId(string $id): ?AEntity
+    {
+        $descriptor = explode("_", $id);
+        if(sizeof($descriptor) === 1) {
+            if(ctype_digit($descriptor[0])) {
+                $audio = (new Audios)->get((int) $descriptor[0]);
+            } else {
+                $aid = base64_decode($descriptor[0], true);
+                if(!$aid)
+                    $this->fail(8, "Invalid audio $id");
+
+                $audio = (new Audios)->get((int) $aid);
+            }
+        } else if(sizeof($descriptor) === 2) {
+            $audio = (new Audios)->getByOwnerAndVID((int) $descriptor[0], (int) $descriptor[1]);
+        } else {
+            $this->fail(8, "Invalid audio $id");
+        }
+
+        return $audio;
+    }
+
+    function getById(string $audios, ?string $hash = NULL, int $need_user = 0): object
+    {
+        $this->requireUser();
+
+        $audioIds = array_unique(explode(",", $audios));
+        if(sizeof($audioIds) === 1) {
+            $audio = $this->audioFromAnyId($audioIds[0]);
+
+            return (object) [
+                "count" => 1,
+                "items" => [
+                    $this->toSafeAudioStruct($audio, $hash, (bool) $need_user),
+                ],
+            ];
+        } else if(sizeof($audioIds) > 6000) {
+            $this->fail(1980, "Can't get more than 6000 audios at once");
+        }
+
+        $audios = [];
+        foreach($audioIds as $id)
+            $audios[] = $this->getById($id, $hash)->items[0];
+
+        return (object) [
+            "count" => sizeof($audios),
+            "items" => $audios,
+        ];
+    }
+
+    function isLagtrain(string $audio_id): int
+    {
+        $this->requireUser();
+
+        $audio = $this->audioFromAnyId($audio_id);
+        if(!$audio)
+            $this->fail(0404, "Audio not found");
+
+        # Possible information disclosure risks are acceptable :D
+        return (int) (strpos($audio->getName(), "Lagtrain") !== false);
+    }
+
+    // TODO stub
+    function getRecommendations(): object
+    {
+        return (object) [
+            "count" => 0,
+            "items" => [],
+        ];
+    }
+
+    function getPopular(?int $genre_id = NULL, ?string $genre_str = NULL, int $offset = 0, int $count = 100, ?string $hash = NULL): object
+    {
+        $this->requireUser();
+        $this->validateGenre($genre_str, $genre_id);
+
+        $results = (new Audios)->getGlobal(Audios::ORDER_POPULAR, $genre_str);
+
+        return $this->streamToResponse($results, $offset, $count, $hash);
+    }
+
+    function getFeed(?int $genre_id = NULL, ?string $genre_str = NULL, int $offset = 0, int $count = 100, ?string $hash = NULL): object
+    {
+        $this->requireUser();
+        $this->validateGenre($genre_str, $genre_id);
+
+        $results = (new Audios)->getGlobal(Audios::ORDER_NEW, $genre_str);
+
+        return $this->streamToResponse($results, $offset, $count, $hash);
+    }
+
+    function search(string $q, int $auto_complete = 0, int $lyrics = 0, int $performer_only = 0, int $sort = 2, int $search_own = 0, int $offset = 0, int $count = 30, ?string $hash = NULL): object
+    {
+        $this->requireUser();
+
+        if(($auto_complete + $search_own) != 0)
+            $this->fail(10, "auto_complete and search_own are not supported");
+        else if($count > 300 || $count < 1)
+            $this->fail(8, "count is invalid: $count");
+
+        $results = (new Audios)->search($q, $sort, (bool) $performer_only, (bool) $lyrics);
+
+        return $this->streamToResponse($results, $offset, $count, $hash);
+    }
+
+    function getCount(int $owner_id, int $uploaded_only = 0): int
+    {
+        $this->requireUser();
+
+        if($owner_id < 0) {
+            $owner_id *= -1;
+            $group = (new Clubs)->get($owner_id);
+            if(!$group)
+                $this->fail(0404, "Group not found");
+
+            return (new Audios)->getClubCollectionSize($group);
+        }
+
+        $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id);
+        if(!$user)
+            $this->fail(0404, "User not found");
+
+        if(!$user->getPrivacyPermission("audios.read", $this->getUser()))
+            $this->fail(15, "Access denied");
+
+        if($uploaded_only) {
+            return DatabaseConnection::i()->getContext()->table("audios")
+                ->where([
+                    "deleted" => false,
+                    "owner" => $owner_id,
+                ])->count();
+        }
+
+        return (new Audios)->getUserCollectionSize($user);
+    }
+
+	function get(int $owner_id = 0, int $album_id = 0, string $audio_ids = '', int $need_user = 1, int $offset = 0, int $count = 100, int $uploaded_only = 0, int $need_seed = 0, ?string $shuffle_seed = NULL, int $shuffle = 0, ?string $hash = NULL): object
 	{
-		$serverUrl = ovk_scheme(true) . $_SERVER["SERVER_NAME"];
-	
-		return (object) [
-			"count" => 1,
-			"items" => [(object) [
-				"id" 	   => 1,
-				"owner_id" => 1,
-				"artist"   => "В ОВК ПОКА НЕТ МУЗЫКИ",
-				"title"    => "ЖДИТЕ :)))",
-				"duration" => 22,
-				"url"      => $serverUrl . "/assets/packages/static/openvk/audio/nomusic.mp3"
-			]]
-		];
+		$this->requireUser();
+
+        $shuffleSeed    = NULL;
+        $shuffleSeedStr = NULL;
+        if($shuffle == 1) {
+            if(!$shuffle_seed) {
+                if($need_seed == 1) {
+                    $shuffleSeed    = openssl_random_pseudo_bytes(6);
+                    $shuffleSeedStr = base64_encode($shuffleSeed);
+                    $shuffleSeed    = hexdec(bin2hex($shuffleSeed));
+                } else {
+                    $hOffset        = ((int) date("i") * 60) + (int) date("s");
+                    $thisHour       = time() - $hOffset;
+                    $shuffleSeed    = $thisHour + $this->getUser()->getId();
+                    $shuffleSeedStr = base64_encode(hex2bin(dechex($shuffleSeed)));
+                }
+            } else {
+                $shuffleSeed    = hexdec(bin2hex(base64_decode($shuffle_seed)));
+                $shuffleSeedStr = $shuffle_seed;
+            }
+        }
+
+        if($album_id != 0) {
+            $album = (new Audios)->getPlaylist($album_id);
+            if(!$album)
+                $this->fail(0404, "album_id invalid");
+            else if(!$album->canBeViewedBy($this->getUser()))
+                $this->fail(600, "Can't open this album for reading");
+
+            $songs = [];
+            $list  = $album->getAudios($offset, $count, $shuffleSeed);
+
+            foreach($list as $song)
+                $songs[] = $this->toSafeAudioStruct($song, $hash, $need_user == 1);
+
+            $response = (object) [
+                "count" => sizeof($songs),
+                "items" => $songs,
+            ];
+            if(!is_null($shuffleSeed))
+                $response->shuffle_seed = $shuffleSeedStr;
+
+            return $response;
+        }
+
+        if(!empty($audio_ids)) {
+            $audio_ids = explode(",", $audio_ids);
+            if(!$audio_ids)
+                $this->fail(10, "Audio::get@L0d186:explode(string): Unknown error");
+            else if(sizeof($audio_ids) < 1)
+                $this->fail(8, "Invalid audio_ids syntax");
+
+            if(!is_null($shuffleSeed))
+                $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed);
+
+            $obj = $this->getById(implode(",", $audio_ids), $hash, $need_user);
+            if(!is_null($shuffleSeed))
+                $obj->shuffle_seed = $shuffleSeedStr;
+
+            return $obj;
+        }
+
+        $dbCtx = DatabaseConnection::i()->getContext();
+        if($uploaded_only == 1) {
+            if($owner_id <= 0)
+                $this->fail(8, "uploaded_only can only be used with owner_id > 0");
+
+            $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id);
+
+            if(!$user) 
+                $this->fail(0602, "Invalid user");
+
+            if(!$user->getPrivacyPermission("audios.read", $this->getUser()))
+                $this->fail(15, "Access denied: this user chose to hide his audios");
+
+            if(!is_null($shuffleSeed)) {
+                $audio_ids = [];
+                $query     = $dbCtx->table("audios")->select("virtual_id")->where([
+                    "owner"   => $owner_id,
+                    "deleted" => 0,
+                ]);
+
+                foreach($query as $res)
+                    $audio_ids[] = $res->virtual_id;
+
+                $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed);
+                $audio_ids = array_slice($audio_ids, $offset, $count);
+                $audio_q   = ""; # audio.getById query
+                foreach($audio_ids as $aid)
+                    $audio_q .= ",$owner_id" . "_$aid";
+
+                $obj = $this->getById(substr($audio_q, 1), $hash, $need_user);
+                $obj->shuffle_seed = $shuffleSeedStr;
+
+                return $obj;
+            }
+
+            $res = (new Audios)->getByUploader((new \openvk\Web\Models\Repositories\Users)->get($owner_id));
+
+            return $this->streamToResponse($res, $offset, $count, $hash, $need_user);
+        }
+
+        $query = $dbCtx->table("audio_relations")->select("audio")->where("entity", $owner_id);
+        if(!is_null($shuffleSeed)) {
+            $audio_ids = [];
+            foreach($query as $aid)
+                $audio_ids[] = $aid->audio;
+
+            $audio_ids = knuth_shuffle($audio_ids, $shuffleSeed);
+            $audio_ids = array_slice($audio_ids, $offset, $count);
+            $audio_q   = "";
+            foreach($audio_ids as $aid)
+                $audio_q .= ",$aid";
+
+            $obj = $this->getById(substr($audio_q, 1), $hash, $need_user);
+            $obj->shuffle_seed = $shuffleSeedStr;
+
+            return $obj;
+        }
+
+        $items  = [];
+
+        if($owner_id > 0) {
+            $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id);
+
+            if(!$user)
+                $this->fail(50, "Invalid user");
+
+            if(!$user->getPrivacyPermission("audios.read", $this->getUser()))
+                $this->fail(15, "Access denied: this user chose to hide his audios");
+        }
+
+        $audios = (new Audios)->getByEntityID($owner_id, $offset, $count);
+        foreach($audios as $audio)
+            $items[] = $this->toSafeAudioStruct($audio, $hash, $need_user == 1);
+
+        return (object) [
+            "count" => sizeof($items),
+            "items" => $items,
+        ];
 	}
+
+    function getLyrics(int $lyrics_id): object
+    {
+        $this->requireUser();
+
+        $audio = (new Audios)->get($lyrics_id);
+        if(!$audio || !$audio->getLyrics())
+            $this->fail(0404, "Not found");
+
+        if(!$audio->canBeViewedBy($this->getUser()))
+            $this->fail(201, "Access denied to lyrics");
+
+        return (object) [
+            "lyrics_id" => $lyrics_id,
+            "text"      => preg_replace("%\r\n?%", "\n", $audio->getLyrics()),
+        ];
+    }
+
+    function beacon(int $aid, ?int $gid = NULL): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $audio = (new Audios)->get($aid);
+        if(!$audio)
+            $this->fail(0404, "Not Found");
+        else if(!$audio->canBeViewedBy($this->getUser()))
+            $this->fail(201, "Insufficient permissions to listen this audio");
+
+        $group = NULL;
+        if(!is_null($gid)) {
+            $group = (new Clubs)->get($gid);
+            if(!$group)
+                $this->fail(0404, "Not Found");
+            else if(!$group->canBeModifiedBy($this->getUser()))
+                $this->fail(203, "Insufficient rights to this group");
+        }
+
+        return (int) $audio->listen($group ?? $this->getUser());
+    }
+
+    function setBroadcast(string $audio, string $target_ids): array
+    {
+        $this->requireUser();
+
+        [$owner, $aid] = explode("_", $audio);
+        $song = (new Audios)->getByOwnerAndVID((int) $owner, (int) $aid);
+        $ids  = [];
+        foreach(explode(",", $target_ids) as $id) {
+            $id = (int) $id;
+            if($id > 0) {
+                if ($id != $this->getUser()->getId()) {
+                    $this->fail(600, "Can't listen on behalf of $id");
+                } else {
+                    $ids[] = $id;
+                    $this->beacon($song->getId());
+                    continue;
+                }
+            }
+
+            $group = (new Clubs)->get($id * -1);
+            if(!$group)
+                $this->fail(0404, "Not Found");
+            else if(!$group->canBeModifiedBy($this->getUser()))
+                $this->fail(203,"Insufficient rights to this group");
+
+            $ids[] = $id;
+            $this->beacon($song ? $song->getId() : 0, $id * -1);
+        }
+
+        return $ids;
+    }
+
+    function getBroadcastList(string $filter = "all", int $active = 0, ?string $hash = NULL): object
+    {
+        $this->requireUser();
+
+        if(!in_array($filter, ["all", "friends", "groups"]))
+            $this->fail(8, "Invalid filter $filter");
+
+        $broadcastList = $this->getUser()->getBroadcastList($filter);
+        $items = [];
+        foreach($broadcastList as $res) {
+            $struct = $res->toVkApiStruct();
+            $status = $res->getCurrentAudioStatus();
+
+            $struct->status_audio = $status ? $this->toSafeAudioStruct($status) : NULL;
+            $items[] = $struct;
+        }
+
+        return (object) [
+            "count" => sizeof($items),
+            "items" => $items,
+        ];
+    }
+
+    function edit(int $owner_id, int $audio_id, ?string $artist = NULL, ?string $title = NULL, ?string $text = NULL, ?int $genre_id = NULL, ?string $genre_str = NULL, int $no_search = 0): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id);
+        if(!$audio)
+            $this->fail(0404, "Not Found");
+        else if(!$audio->canBeModifiedBy($this->getUser()))
+            $this->fail(201, "Insufficient permissions to edit this audio");
+
+        if(!is_null($genre_id)) {
+            $genre = array_flip(AEntity::vkGenres)[$genre_id] ?? NULL;
+            if(!$genre)
+                $this->fail(8, "Invalid genre ID $genre_id");
+
+            $audio->setGenre($genre);
+        } else if(!is_null($genre_str)) {
+            if(!in_array($genre_str, AEntity::genres))
+                $this->fail(8, "Invalid genre ID $genre_str");
+
+            $audio->setGenre($genre_str);
+        }
+
+        $lyrics = 0;
+        if(!is_null($text)) {
+            $audio->setLyrics($text);
+            $lyrics = $audio->getId();
+        }
+
+        if(!is_null($artist))
+            $audio->setPerformer($artist);
+
+        if(!is_null($title))
+            $audio->setName($title);
+
+        $audio->setSearchability(!((bool) $no_search));
+        $audio->setEdited(time());
+        $audio->save();
+
+        return $lyrics;
+    }
+
+    function add(int $audio_id, int $owner_id, ?int $group_id = NULL, ?int $album_id = NULL): string
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        if(!is_null($album_id))
+            $this->fail(10, "album_id not implemented");
+
+        // TODO get rid of dups
+        $to = $this->getUser();
+        if(!is_null($group_id)) {
+            $group = (new Clubs)->get($group_id);
+            if(!$group)
+                $this->fail(0404, "Invalid group_id");
+            else if(!$group->canBeModifiedBy($this->getUser()))
+                $this->fail(203, "Insufficient rights to this group");
+
+            $to = $group;
+        }
+
+        $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id);
+        if(!$audio)
+            $this->fail(0404, "Not found");
+        else if(!$audio->canBeViewedBy($this->getUser()))
+            $this->fail(201, "Access denied to audio(owner=$owner_id, vid=$audio_id)");
+
+        try {
+            $audio->add($to);
+        } catch(\OverflowException $ex) {
+            $this->fail(300, "Album is full");
+        }
+
+        return $audio->getPrettyId();
+    }
+
+    function delete(int $audio_id, int $owner_id, ?int $group_id = NULL): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $from = $this->getUser();
+        if(!is_null($group_id)) {
+            $group = (new Clubs)->get($group_id);
+            if(!$group)
+                $this->fail(0404, "Invalid group_id");
+            else if(!$group->canBeModifiedBy($this->getUser()))
+                $this->fail(203, "Insufficient rights to this group");
+
+            $from = $group;
+        }
+
+        $audio = (new Audios)->getByOwnerAndVID($owner_id, $audio_id);
+        if(!$audio)
+            $this->fail(0404, "Not found");
+
+        $audio->remove($from);
+
+        return 1;
+    }
+
+    function restore(int $audio_id, int $owner_id, ?int $group_id = NULL, ?string $hash = NULL): object
+    {
+        $this->requireUser();
+
+        $vid = $this->add($audio_id, $owner_id, $group_id);
+
+        return $this->getById($vid, $hash)->items[0];
+    }
+
+    function getAlbums(int $owner_id = 0, int $offset = 0, int $count = 50, int $drop_private = 1): object
+    {
+        $this->requireUser();
+
+        $owner_id  = $owner_id == 0 ? $this->getUser()->getId() : $owner_id;
+        $playlists = [];
+
+        if($owner_id > 0 && $owner_id != $this->getUser()->getId()) {
+            $user = (new \openvk\Web\Models\Repositories\Users)->get($owner_id);
+
+            if(!$user->getPrivacyPermission("audios.read", $this->getUser()))
+                $this->fail(50, "Access to playlists denied");
+        }
+
+        foreach((new Audios)->getPlaylistsByEntityId($owner_id, $offset, $count) as $playlist) {
+            if(!$playlist->canBeViewedBy($this->getUser())) {
+                if($drop_private == 1)
+                    continue;
+
+                $playlists[] = NULL;
+                continue;
+            }
+
+            $playlists[] = $playlist->toVkApiStruct($this->getUser());
+        }
+
+        return (object) [
+            "count" => sizeof($playlists),
+            "items" => $playlists,
+        ];
+    }
+
+    function searchAlbums(string $query, int $offset = 0, int $limit = 25, int $drop_private = 0): object
+    {
+        $this->requireUser();
+
+        $playlists = [];
+        $search    = (new Audios)->searchPlaylists($query)->offsetLimit($offset, $limit);
+        foreach($search as $playlist) {
+            if(!$playlist->canBeViewedBy($this->getUser())) {
+                if($drop_private == 0)
+                    $playlists[] = NULL;
+
+                continue;
+            }
+
+            $playlists[] = $playlist->toVkApiStruct($this->getUser());
+        }
+
+        return (object) [
+            "count" => sizeof($playlists),
+            "items" => $playlists,
+        ];
+    }
+
+    function addAlbum(string $title, ?string $description = NULL, int $group_id = 0): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $group = NULL;
+        if($group_id != 0) {
+            $group = (new Clubs)->get($group_id);
+            if(!$group)
+                $this->fail(0404, "Invalid group_id");
+            else if(!$group->canBeModifiedBy($this->getUser()))
+                $this->fail(600, "Insufficient rights to this group");
+        }
+
+        $album = new Playlist;
+        $album->setName($title);
+        if(!is_null($group))
+            $album->setOwner($group_id * -1);
+        else
+            $album->setOwner($this->getUser()->getId());
+
+        if(!is_null($description))
+            $album->setDescription($description);
+
+        $album->save();
+        if(!is_null($group))
+            $album->bookmark($group);
+        else
+            $album->bookmark($this->getUser());
+
+        return $album->getId();
+    }
+
+    function editAlbum(int $album_id, ?string $title = NULL, ?string $description = NULL): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $album = (new Audios)->getPlaylist($album_id);
+        if(!$album)
+            $this->fail(0404, "Album not found");
+        else if(!$album->canBeModifiedBy($this->getUser()))
+            $this->fail(600, "Insufficient rights to this album");
+
+        if(!is_null($title))
+            $album->setName($title);
+
+        if(!is_null($description))
+            $album->setDescription($description);
+
+        $album->setEdited(time());
+        $album->save();
+
+        return (int) !(!$title && !$description);
+    }
+
+    function deleteAlbum(int $album_id): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $album = (new Audios)->getPlaylist($album_id);
+        if(!$album)
+            $this->fail(0404, "Album not found");
+        else if(!$album->canBeModifiedBy($this->getUser()))
+            $this->fail(600, "Insufficient rights to this album");
+
+        $album->delete();
+
+        return 1;
+    }
+
+    function moveToAlbum(int $album_id, string $audio_ids): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $album = (new Audios)->getPlaylist($album_id);
+        if(!$album)
+            $this->fail(0404, "Album not found");
+        else if(!$album->canBeModifiedBy($this->getUser()))
+            $this->fail(600, "Insufficient rights to this album");
+
+        $audios    = [];
+        $audio_ids = array_unique(explode(",", $audio_ids));
+        if(sizeof($audio_ids) < 1 || sizeof($audio_ids) > 1000)
+            $this->fail(8, "audio_ids must contain at least 1 audio and at most 1000");
+
+        foreach($audio_ids as $audio_id) {
+            $audio = $this->audioFromAnyId($audio_id);
+            if(!$audio)
+                continue;
+            else if(!$audio->canBeViewedBy($this->getUser()))
+                continue;
+
+            $audios[] = $audio;
+        }
+
+        if(sizeof($audios) < 1)
+            return 0;
+
+        $res = 1;
+        try {
+            foreach ($audios as $audio)
+                $res = min($res, (int) $album->add($audio));
+        } catch(\OutOfBoundsException $ex) {
+            return 0;
+        }
+
+        return $res;
+    }
+
+    function removeFromAlbum(int $album_id, string $audio_ids): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $album = (new Audios)->getPlaylist($album_id);
+        if(!$album)
+            $this->fail(0404, "Album not found");
+        else if(!$album->canBeModifiedBy($this->getUser()))
+            $this->fail(600, "Insufficient rights to this album");
+
+        $audios    = [];
+        $audio_ids = array_unique(explode(",", $audio_ids));
+        if(sizeof($audio_ids) < 1 || sizeof($audio_ids) > 1000)
+            $this->fail(8, "audio_ids must contain at least 1 audio and at most 1000");
+
+        foreach($audio_ids as $audio_id) {
+            $audio = $this->audioFromAnyId($audio_id);
+            if(!$audio)
+                continue;
+            else if($audio->canBeViewedBy($this->getUser()))
+                continue;
+
+            $audios[] = $audio;
+        }
+
+        if(sizeof($audios) < 1)
+            return 0;
+
+        foreach($audios as $audio)
+            $album->remove($audio);
+
+        return 1;
+    }
+
+    function copyToAlbum(int $album_id, string $audio_ids): int
+    {
+        return $this->moveToAlbum($album_id, $audio_ids);
+    }
+
+    function bookmarkAlbum(int $id): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $album = (new Audios)->getPlaylist($id);
+        if(!$album)
+            $this->fail(0404, "Not found");
+
+        if(!$album->canBeViewedBy($this->getUser()))
+            $this->fail(600, "Access error");
+
+        return (int) $album->bookmark($this->getUser());
+    }
+
+    function unBookmarkAlbum(int $id): int
+    {
+        $this->requireUser();
+        $this->willExecuteWriteAction();
+
+        $album = (new Audios)->getPlaylist($id);
+        if(!$album)
+            $this->fail(0404, "Not found");
+
+        if(!$album->canBeViewedBy($this->getUser()))
+            $this->fail(600, "Access error");
+
+        return (int) $album->unbookmark($this->getUser());
+    }
 }
diff --git a/VKAPI/Handlers/Groups.php b/VKAPI/Handlers/Groups.php
index 3123a43f..85a251da 100644
--- a/VKAPI/Handlers/Groups.php
+++ b/VKAPI/Handlers/Groups.php
@@ -292,7 +292,8 @@ final class Groups extends VKAPIRequestHandler
                 int    $topics = NULL, 
                 int    $adminlist = NULL,
                 int    $topicsAboveWall = NULL,
-                int    $hideFromGlobalFeed = NULL)
+                int    $hideFromGlobalFeed = NULL,
+                int    $audio = NULL)
     {
         $this->requireUser();
         $this->willExecuteWriteAction();
@@ -303,17 +304,22 @@ final class Groups extends VKAPIRequestHandler
         if(!$club || !$club->canBeModifiedBy($this->getUser())) $this->fail(15, "You can't modify this group.");
         if(!empty($screen_name) && !$club->setShortcode($screen_name)) $this->fail(103, "Invalid shortcode.");
 
-        !is_null($title)              ? $club->setName($title) : NULL;
-        !is_null($description)        ? $club->setAbout($description) : NULL;
-        !is_null($screen_name)        ? $club->setShortcode($screen_name) : NULL;
-        !is_null($website)            ? $club->setWebsite((!parse_url($website, PHP_URL_SCHEME) ? "https://" : "") . $website) : NULL;
-        !is_null($wall)               ? $club->setWall($wall) : NULL;
-        !is_null($topics)             ? $club->setEveryone_Can_Create_Topics($topics) : NULL;
-        !is_null($adminlist)          ? $club->setAdministrators_List_Display($adminlist) : NULL;
-        !is_null($topicsAboveWall)    ? $club->setDisplay_Topics_Above_Wall($topicsAboveWall) : NULL;
-        !is_null($hideFromGlobalFeed) ? $club->setHide_From_Global_Feed($hideFromGlobalFeed) : NULL;
+        !empty($title)              ? $club->setName($title) : NULL;
+        !empty($description)        ? $club->setAbout($description) : NULL;
+        !empty($screen_name)        ? $club->setShortcode($screen_name) : NULL;
+        !empty($website)            ? $club->setWebsite((!parse_url($website, PHP_URL_SCHEME) ? "https://" : "") . $website) : NULL;
+        !empty($wall)               ? $club->setWall($wall) : NULL;
+        !empty($topics)             ? $club->setEveryone_Can_Create_Topics($topics) : NULL;
+        !empty($adminlist)          ? $club->setAdministrators_List_Display($adminlist) : NULL;
+        !empty($topicsAboveWall)    ? $club->setDisplay_Topics_Above_Wall($topicsAboveWall) : NULL;
+        !empty($hideFromGlobalFeed) ? $club->setHide_From_Global_Feed($hideFromGlobalFeed) : NULL;
+        in_array($audio, [0, 1]) ? $club->setEveryone_can_upload_audios($audio) : NULL;
 
-        $club->save();
+        try {
+            $club->save();
+        } catch(\TypeError $e) {
+            $this->fail(8, "Nothing changed");
+        }
 
         return 1;
     }
@@ -370,7 +376,7 @@ final class Groups extends VKAPIRequestHandler
                         $arr->items[$i]->can_see_all_posts = 1;
                         break;
                     case "can_see_audio":
-                        $arr->items[$i]->can_see_audio = 0;
+                        $arr->items[$i]->can_see_audio = 1;
                         break;
                     case "can_write_private_message":
                         $arr->items[$i]->can_write_private_message = 0;
@@ -469,7 +475,7 @@ final class Groups extends VKAPIRequestHandler
             "wall"           => $club->canPost() == true ? 1 : 0,
             "photos"         => 1,
             "video"          => 0,
-            "audio"          => 0,
+            "audio"          => $club->isEveryoneCanUploadAudios() ? 1 : 0,
             "docs"           => 0,
             "topics"         => $club->isEveryoneCanCreateTopics() == true ? 1 : 0,
             "wiki"           => 0,
diff --git a/VKAPI/Handlers/Status.php b/VKAPI/Handlers/Status.php
index 843f42bd..a1b104a2 100644
--- a/VKAPI/Handlers/Status.php
+++ b/VKAPI/Handlers/Status.php
@@ -8,13 +8,23 @@ final class Status extends VKAPIRequestHandler
     function get(int $user_id = 0, int $group_id = 0)
     {
         $this->requireUser();
-        if($user_id == 0 && $group_id == 0) {
-            return $this->getUser()->getStatus();
-        } else {
-            if($group_id > 0)
-                $this->fail(501, "Group statuses are not implemented");
-            else
-                return (new UsersRepo)->get($user_id)->getStatus();
+        
+        if($user_id == 0 && $group_id == 0)
+            $user_id = $this->getUser()->getId();
+
+        if($group_id > 0)
+            $this->fail(501, "Group statuses are not implemented");
+        else {
+            $user = (new UsersRepo)->get($user_id);
+            $audioStatus = $user->getCurrentAudioStatus();
+            if($audioStatus) {
+                return [
+                    "status" => $user->getStatus(),
+                    "audio" => $audioStatus->toVkApiStruct(),
+                ];
+            }
+
+            return $user->getStatus();
         }
     }
 
diff --git a/VKAPI/Handlers/Users.php b/VKAPI/Handlers/Users.php
index a4298787..68bf828f 100644
--- a/VKAPI/Handlers/Users.php
+++ b/VKAPI/Handlers/Users.php
@@ -95,6 +95,12 @@ final class Users extends VKAPIRequestHandler
 							case "status":
 								if($usr->getStatus() != NULL)
 									$response[$i]->status = $usr->getStatus();
+								
+								$audioStatus = $usr->getCurrentAudioStatus();
+
+								if($audioStatus)
+									$response[$i]->status_audio = $audioStatus->toVkApiStruct();
+
 								break;
 							case "screen_name":
 								if($usr->getShortCode() != NULL)
diff --git a/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php
index 6b78a0b0..693ca3cb 100644
--- a/VKAPI/Handlers/Wall.php
+++ b/VKAPI/Handlers/Wall.php
@@ -15,6 +15,7 @@ use openvk\Web\Models\Entities\Video;
 use openvk\Web\Models\Repositories\Videos as VideosRepo;
 use openvk\Web\Models\Entities\Note;
 use openvk\Web\Models\Repositories\Notes as NotesRepo;
+use openvk\Web\Models\Repositories\Audios as AudiosRepo;
 
 final class Wall extends VKAPIRequestHandler
 {
@@ -58,6 +59,8 @@ final class Wall extends VKAPIRequestHandler
                     $attachments[] = $attachment->getApiStructure();
                 } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) {
                     $attachments[] = $attachment->toVkApiStruct();
+                } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) {
+                    $attachments[] = $attachment->toVkApiStruct($this->getUser());
                 } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) {
                     $repostAttachments = [];
 
@@ -233,6 +236,8 @@ final class Wall extends VKAPIRequestHandler
                         $attachments[] = $attachment->getApiStructure();
                     } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) {
                         $attachments[] = $attachment->toVkApiStruct();
+                    } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) {
+                        $attachments[] = $attachment->toVkApiStruct($this->getUser());
                     } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) {
                         $repostAttachments = [];
 
@@ -450,6 +455,8 @@ final class Wall extends VKAPIRequestHandler
                     $attachmentType = "video";
                 elseif(str_contains($attac, "note"))
                     $attachmentType = "note";
+                elseif(str_contains($attac, "audio"))
+                    $attachmentType = "audio";
                 else
                     $this->fail(205, "Unknown attachment type");
 
@@ -483,6 +490,12 @@ final class Wall extends VKAPIRequestHandler
                     if(!$attacc->getOwner()->getPrivacyPermission('notes.read', $this->getUser()))
                         $this->fail(11, "Access to note denied");
 
+                    $post->attach($attacc);
+                } elseif($attachmentType == "audio") {
+                    $attacc = (new AudiosRepo)->getByOwnerAndVID($attachmentOwner, $attachmentId);
+                    if(!$attacc || $attacc->isDeleted())
+                        $this->fail(100, "Audio does not exist");
+
                     $post->attach($attacc);
                 }
             }
@@ -562,6 +575,8 @@ final class Wall extends VKAPIRequestHandler
                     $attachments[] = $this->getApiPhoto($attachment);
                 } elseif($attachment instanceof \openvk\Web\Models\Entities\Note) {
                     $attachments[] = $attachment->toVkApiStruct();
+                } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) {
+                    $attachments[] = $attachment->toVkApiStruct($this->getUser());
                 }
             }
 
@@ -628,6 +643,8 @@ final class Wall extends VKAPIRequestHandler
         foreach($comment->getChildren() as $attachment) {
             if($attachment instanceof \openvk\Web\Models\Entities\Photo) {
                 $attachments[] = $this->getApiPhoto($attachment);
+            } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) {
+                $attachments[] = $attachment->toVkApiStruct($this->getUser());
             }
         }
 
@@ -719,6 +736,8 @@ final class Wall extends VKAPIRequestHandler
                     $attachmentType = "photo";
                 elseif(str_contains($attac, "video"))
                     $attachmentType = "video";
+                elseif(str_contains($attac, "audio"))
+                    $attachmentType = "audio";
                 else
                     $this->fail(205, "Unknown attachment type");
 
@@ -744,6 +763,12 @@ final class Wall extends VKAPIRequestHandler
                     if(!$attacc->getOwner()->getPrivacyPermission('videos.read', $this->getUser()))
                         $this->fail(11, "Access to video denied");
 
+                    $comment->attach($attacc);
+                } elseif($attachmentType == "audio") {
+                    $attacc = (new AudiosRepo)->getByOwnerAndVID($attachmentOwner, $attachmentId);
+                    if(!$attacc || $attacc->isDeleted())
+                        $this->fail(100, "Audio does not exist");
+
                     $comment->attach($attacc);
                 }
             }
diff --git a/Web/Models/Entities/Audio.php b/Web/Models/Entities/Audio.php
new file mode 100644
index 00000000..11d8502b
--- /dev/null
+++ b/Web/Models/Entities/Audio.php
@@ -0,0 +1,469 @@
+<?php declare(strict_types=1);
+namespace openvk\Web\Models\Entities;
+use Chandler\Database\DatabaseConnection;
+use openvk\Web\Util\Shell\Exceptions\UnknownCommandException;
+use openvk\Web\Util\Shell\Shell;
+
+/**
+ * @method setName(string)
+ * @method setPerformer(string)
+ * @method setLyrics(string)
+ * @method setExplicit(bool)
+ */
+class Audio extends Media
+{
+    protected $tableName     = "audios";
+    protected $fileExtension = "mpd";
+
+    # Taken from winamp :D
+    const genres = [
+        'Blues','Big Band','Classic Rock','Chorus','Country','Easy Listening','Dance','Acoustic','Disco','Humour','Funk','Speech','Grunge','Chanson','Hip-Hop','Opera','Jazz','Chamber Music','Metal','Sonata','New Age','Symphony','Oldies','Booty Bass','Other','Primus','Pop','Porn Groove','R&B','Satire','Rap','Slow Jam','Reggae','Club','Rock','Tango','Techno','Samba','Industrial','Folklore','Alternative','Ballad','Ska','Power Ballad','Death Metal','Rhythmic Soul','Pranks','Freestyle','Soundtrack','Duet','Euro-Techno','Punk Rock','Ambient','Drum Solo','Trip-Hop','A Cappella','Vocal','Euro-House','Jazz+Funk','Dance Hall','Fusion','Goa','Trance','Drum & Bass','Classical','Club-House','Instrumental','Hardcore','Acid','Terror','House','Indie','Game','BritPop','Sound Clip','Negerpunk','Gospel','Polsk Punk','Noise','Beat','AlternRock','Christian Gangsta Rap','Bass','Heavy Metal','Soul','Black Metal','Punk','Crossover','Space','Contemporary Christian','Meditative','Christian Rock','Instrumental Pop','Merengue','Instrumental Rock','Salsa','Ethnic','Thrash Metal','Gothic','Anime','Darkwave','JPop','Techno-Industrial','Synthpop','Electronic','Abstract','Pop-Folk','Art Rock','Eurodance','Baroque','Dream','Bhangra','Southern Rock','Big Beat','Comedy','Breakbeat','Cult','Chillout','Gangsta Rap','Downtempo','Top 40','Dub','Christian Rap','EBM','Pop / Funk','Eclectic','Jungle','Electro','Native American','Electroclash','Cabaret','Emo','New Wave','Experimental','Psychedelic','Garage','Rave','Global','Showtunes','IDM','Trailer','Illbient','Lo-Fi','Industro-Goth','Tribal','Jam Band','Acid Punk','Krautrock','Acid Jazz','Leftfield','Polka','Lounge','Retro','Math Rock','Musical','New Romantic','Rock & Roll','Nu-Breakz','Hard Rock','Post-Punk','Folk','Post-Rock','Folk-Rock','Psytrance','National Folk','Shoegaze','Swing','Space Rock','Fast Fusion','Trop Rock','Bebob','World Music','Latin','Neoclassical','Revival','Audiobook','Celtic','Audio Theatre','Bluegrass','Neue Deutsche Welle','Avantgarde','Podcast','Gothic Rock','Indie Rock','Progressive Rock','G-Funk','Psychedelic Rock','Dubstep','Symphonic Rock','Garage Rock','Slow Rock','Psybient','Psychobilly','Touhou'
+    ];
+
+    # Taken from: https://web.archive.org/web/20220322153107/https://dev.vk.com/reference/objects/audio-genres
+    const vkGenres = [
+        "Rock"               => 1,
+        "Pop"                => 2,
+        "Rap"                => 3,
+        "Hip-Hop"            => 3, # VK API lists №3 as Rap & Hip-Hop, but these genres are distinct in OpenVK
+        "Easy Listening"     => 4,
+        "House"              => 5,
+        "Dance"              => 5,
+        "Instrumental"       => 6,
+        "Metal"              => 7,
+        "Alternative"        => 21,
+        "Dubstep"            => 8,
+        "Jazz"               => 1001,
+        "Blues"              => 1001,
+        "Drum & Bass"        => 10,
+        "Trance"             => 11,
+        "Chanson"            => 12,
+        "Ethnic"             => 13,
+        "Acoustic"           => 14,
+        "Vocal"              => 14,
+        "Reggae"             => 15,
+        "Classical"          => 16,
+        "Indie Pop"          => 17,
+        "Speech"             => 19,
+        "Disco"              => 22,
+        "Other"              => 18,
+    ];
+
+    private function fileLength(string $filename): int
+    {
+        if(!Shell::commandAvailable("ffmpeg") || !Shell::commandAvailable("ffprobe"))
+            throw new \Exception();
+
+        $error   = NULL;
+        $streams = Shell::ffprobe("-i", $filename, "-show_streams", "-select_streams a", "-loglevel error")->execute($error);
+        if($error !== 0)
+            throw new \DomainException("$filename is not recognized as media container");
+        else if(empty($streams) || ctype_space($streams))
+            throw new \DomainException("$filename does not contain any audio streams");
+
+        $vstreams = Shell::ffprobe("-i", $filename, "-show_streams", "-select_streams v", "-loglevel error")->execute($error);
+        
+        # check if audio has cover (attached_pic)
+        preg_match("%attached_pic=([0-1])%", $vstreams, $hasCover);
+        if(!empty($vstreams) && !ctype_space($vstreams) && ((int)($hasCover[1]) !== 1))
+            throw new \DomainException("$filename is a video");
+
+        $durations = [];
+        preg_match_all('%duration=([0-9\.]++)%', $streams, $durations);
+        if(sizeof($durations[1]) === 0)
+            throw new \DomainException("$filename does not contain any meaningful audio streams");
+
+        $length = 0;
+        foreach($durations[1] as $duration) {
+            $duration = floatval($duration);
+            if($duration < 1.0 || $duration > 65536.0)
+                throw new \DomainException("$filename does not contain any meaningful audio streams");
+            else
+                $length = max($length, $duration);
+        }
+
+        return (int) round($length, 0, PHP_ROUND_HALF_EVEN);
+    }
+
+    /**
+     * @throws \Exception
+     */
+    protected function saveFile(string $filename, string $hash): bool
+    {
+        $duration = $this->fileLength($filename);
+
+        $kid = openssl_random_pseudo_bytes(16);
+        $key = openssl_random_pseudo_bytes(16);
+        $tok = openssl_random_pseudo_bytes(28);
+        $ss  = ceil($duration / 15);
+
+        $this->stateChanges("kid", $kid);
+        $this->stateChanges("key", $key);
+        $this->stateChanges("token", $tok);
+        $this->stateChanges("segment_size", $ss);
+        $this->stateChanges("length", $duration);
+
+        try {
+            $args = [
+                str_replace("enabled", "available", OPENVK_ROOT),
+                str_replace("enabled", "available", $this->getBaseDir()),
+                $hash,
+                $filename,
+
+                bin2hex($kid),
+                bin2hex($key),
+                bin2hex($tok),
+                $ss,
+            ];
+
+            if(Shell::isPowershell()) {
+                Shell::powershell("-executionpolicy bypass", "-File", __DIR__ . "/../shell/processAudio.ps1", ...$args)
+                ->start();
+            } else {
+                Shell::bash(__DIR__ . "/../shell/processAudio.sh", ...$args) // Pls workkkkk
+                ->start();                                                    // idk, not tested :")
+            }
+
+            # Wait until processAudio will consume the file
+            $start = time();
+            while(file_exists($filename))
+                if(time() - $start > 5)
+                    throw new \RuntimeException("Timed out waiting FFMPEG");
+
+         } catch(UnknownCommandException $ucex) {
+             exit(OPENVK_ROOT_CONF["openvk"]["debug"] ? "bash/pwsh is not installed" : VIDEOS_FRIENDLY_ERROR);
+         }
+
+        return true;
+    }
+
+    function getTitle(): string
+    {
+        return $this->getRecord()->name;
+    }
+
+    function getPerformer(): string
+    {
+        return $this->getRecord()->performer;
+    }
+
+    function getName(): string
+    {
+        return $this->getPerformer() . " — " . $this->getTitle();
+    }
+
+    function getGenre(): ?string
+    {
+        return $this->getRecord()->genre;
+    }
+
+    function getLyrics(): ?string
+    {
+        return !is_null($this->getRecord()->lyrics) ? htmlspecialchars($this->getRecord()->lyrics, ENT_DISALLOWED | ENT_XHTML) : NULL;
+    }
+
+    function getLength(): int
+    {
+        return $this->getRecord()->length;
+    }
+
+    function getFormattedLength(): string
+    {
+        $len  = $this->getLength();
+        $mins = floor($len / 60);
+        $secs = $len - ($mins * 60);
+
+        return (
+            str_pad((string) $mins, 2, "0", STR_PAD_LEFT)
+            . ":" .
+            str_pad((string) $secs, 2, "0", STR_PAD_LEFT)
+        );
+    }
+
+    function getSegmentSize(): float
+    {
+        return $this->getRecord()->segment_size;
+    }
+
+    function getListens(): int
+    {
+        return $this->getRecord()->listens;
+    }
+
+    function getOriginalURL(bool $force = false): string
+    {
+        $disallowed = !OPENVK_ROOT_CONF["openvk"]["preferences"]["music"]["exposeOriginalURLs"] && !$force;
+        if(!$this->isAvailable() || $disallowed)
+            return ovk_scheme(true)
+                . $_SERVER["HTTP_HOST"] . ":"
+                . $_SERVER["HTTP_PORT"]
+                . "/assets/packages/static/openvk/audio/nomusic.mp3";
+
+        $key = bin2hex($this->getRecord()->token);
+
+        return str_replace(".mpd", "_fragments", $this->getURL()) . "/original_$key.mp3";
+    }
+
+    function getURL(?bool $force = false): string
+    {
+        if ($this->isWithdrawn()) return "";
+
+        return parent::getURL();
+    }
+
+    function getKeys(): array
+    {
+        $keys[bin2hex($this->getRecord()->kid)] = bin2hex($this->getRecord()->key);
+
+        return $keys;
+    }
+
+    function isAnonymous(): bool
+    {
+        return false;
+    }
+
+    function isExplicit(): bool
+    {
+        return (bool) $this->getRecord()->explicit;
+    }
+
+    function isWithdrawn(): bool
+    {
+        return (bool) $this->getRecord()->withdrawn;
+    }
+
+    function isUnlisted(): bool
+    {
+        return (bool) $this->getRecord()->unlisted;
+    }
+
+    # NOTICE may flush model to DB if it was just processed
+    function isAvailable(): bool
+    {
+        if($this->getRecord()->processed)
+            return true;
+
+        # throttle requests to isAvailable to prevent DoS attack if filesystem is actually an S3 storage
+        if(time() - $this->getRecord()->checked < 5)
+            return false;
+
+        try {
+            $fragments = str_replace(".mpd", "_fragments", $this->getFileName());
+            $original = "original_" . bin2hex($this->getRecord()->token) . ".mp3";
+            if(file_exists("$fragments/$original")) {
+                # Original gets uploaded after fragments
+                $this->stateChanges("processed", 0x01);
+
+                return true;
+            }
+        } finally {
+            $this->stateChanges("checked", time());
+            $this->save();
+        }
+
+        return false;
+    }
+
+    function isInLibraryOf($entity): bool
+    {
+        return sizeof(DatabaseConnection::i()->getContext()->table("audio_relations")->where([
+            "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1),
+            "audio"  => $this->getId(),
+        ])) != 0;
+    }
+
+    function add($entity): bool
+    {
+        if($this->isInLibraryOf($entity))
+            return false;
+
+        $entityId  = $entity->getId() * ($entity instanceof Club ? -1 : 1);
+        $audioRels = DatabaseConnection::i()->getContext()->table("audio_relations");
+        if(sizeof($audioRels->where("entity", $entityId)) > 65536)
+            throw new \OverflowException("Can't have more than 65536 audios in a playlist");
+
+        $audioRels->insert([
+            "entity" => $entityId,
+            "audio"  => $this->getId(),
+        ]);
+
+        return true;
+    }
+
+    function remove($entity): bool
+    {
+        if(!$this->isInLibraryOf($entity))
+            return false;
+
+        DatabaseConnection::i()->getContext()->table("audio_relations")->where([
+            "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1),
+            "audio"  => $this->getId(),
+        ])->delete();
+
+        return true;
+    }
+
+    function listen($entity, Playlist $playlist = NULL): bool
+    {
+        $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens");
+        $lastListen   = $listensTable->where([
+            "entity" => $entity->getRealId(),
+            "audio"  => $this->getId(),
+        ])->order("index DESC")->fetch();
+        
+        if(!$lastListen || (time() - $lastListen->time >= $this->getLength())) {
+            $listensTable->insert([
+                "entity" => $entity->getRealId(),
+                "audio"  => $this->getId(),
+                "time"   => time(),
+                "playlist" => $playlist ? $playlist->getId() : NULL,
+            ]);
+
+            if($entity instanceof User) {
+                $this->stateChanges("listens", ($this->getListens() + 1));
+                $this->save();
+
+                if($playlist) {
+                    $playlist->incrementListens();
+                    $playlist->save();
+                }
+            }
+
+            $entity->setLast_played_track($this->getId());
+            $entity->save();
+
+            return true;
+        }
+
+        $lastListen->update([
+            "time" => time(),
+        ]);
+
+        return false;
+    }
+
+    /**
+     * Returns compatible with VK API 4.x, 5.x structure.
+     *
+     * Always sets album(_id) to NULL at this time.
+     * If genre is not present in VK genre list, fallbacks to "Other".
+     * The url and manifest properties will be set to false if the audio can't be played (processing, removed).
+     *
+     * Aside from standard VK properties, this method will also return some OVK extended props:
+     * 1. added - Is in the library of $user?
+     * 2. editable - Can be edited by $user?
+     * 3. withdrawn - Removed due to copyright request?
+     * 4. ready - Can be played at this time?
+     * 5. genre_str - Full name of genre, NULL if it's undefined
+     * 6. manifest - URL to MPEG-DASH manifest
+     * 7. keys - ClearKey DRM keys
+     * 8. explicit - Marked as NSFW?
+     * 9. searchable - Can be found via search?
+     * 10. unique_id - Unique ID of audio
+     *
+     * @notice that in case if exposeOriginalURLs is set to false in config, "url" will always contain link to nomusic.mp3,
+     * unless $forceURLExposure is set to true.
+     *
+     * @notice may trigger db flush if the audio is not processed yet, use with caution on unsaved models.
+     *
+     * @param ?User $user user, relative to whom "added", "editable" will be set
+     * @param bool $forceURLExposure force set "url" regardless of config
+     */
+    function toVkApiStruct(?User $user = NULL, bool $forceURLExposure = false): object
+    {
+        $obj = (object) [];
+        $obj->unique_id  = base64_encode((string) $this->getId());
+        $obj->id         = $obj->aid = $this->getVirtualId();
+        $obj->artist     = $this->getPerformer();
+        $obj->title      = $this->getTitle();
+        $obj->duration   = $this->getLength();
+        $obj->album_id   = $obj->album = NULL; # i forgor to implement
+        $obj->url        = false;
+        $obj->manifest   = false;
+        $obj->keys       = false;
+        $obj->genre_id   = $obj->genre = self::vkGenres[$this->getGenre() ?? ""] ?? 18; # return Other if no match
+        $obj->genre_str  = $this->getGenre();
+        $obj->owner_id   = $this->getOwner()->getId();
+        if($this->getOwner() instanceof Club)
+            $obj->owner_id *= -1;
+
+        $obj->lyrics = NULL;
+        if(!is_null($this->getLyrics()))
+            $obj->lyrics = $this->getId();
+
+        $obj->added      = $user && $this->isInLibraryOf($user);
+        $obj->editable   = $user && $this->canBeModifiedBy($user);
+        $obj->searchable = !$this->isUnlisted();
+        $obj->explicit   = $this->isExplicit();
+        $obj->withdrawn  = $this->isWithdrawn();
+        $obj->ready      = $this->isAvailable() && !$obj->withdrawn;
+        if($obj->ready) {
+            $obj->url      = $this->getOriginalURL($forceURLExposure);
+            $obj->manifest = $this->getURL();
+            $obj->keys     = $this->getKeys();
+        }
+
+        return $obj;
+    }
+
+    function setOwner(int $oid): void
+    {
+        # WARNING: API implementation won't be able to handle groups like that, don't remove
+        if($oid <= 0)
+            throw new \OutOfRangeException("Only users can be owners of audio!");
+
+        $this->stateChanges("owner", $oid);
+    }
+
+    function setGenre(string $genre): void
+    {
+        if(!in_array($genre, Audio::genres)) {
+            $this->stateChanges("genre", NULL);
+            return;
+        }
+
+        $this->stateChanges("genre", $genre);
+    }
+
+    function setCopyrightStatus(bool $withdrawn = true): void {
+        $this->stateChanges("withdrawn", $withdrawn);
+    }
+
+    function setSearchability(bool $searchable = true): void {
+        $this->stateChanges("unlisted", !$searchable);
+    }
+
+    function setToken(string $tok): void {
+        throw new \LogicException("Changing keys is not supported.");
+    }
+
+    function setKid(string $kid): void {
+        throw new \LogicException("Changing keys is not supported.");
+    }
+
+    function setKey(string $key): void {
+        throw new \LogicException("Changing keys is not supported.");
+    }
+
+    function setLength(int $len): void {
+        throw new \LogicException("Changing length is not supported.");
+    }
+
+    function setSegment_Size(int $len): void {
+        throw new \LogicException("Changing length is not supported.");
+    }
+
+    function delete(bool $softly = true): void
+    {
+        $ctx = DatabaseConnection::i()->getContext();
+        $ctx->table("audio_relations")->where("audio", $this->getId())
+            ->delete();
+        $ctx->table("audio_listens")->where("audio", $this->getId())
+            ->delete();
+        $ctx->table("playlist_relations")->where("media", $this->getId())
+            ->delete();
+
+        parent::delete($softly);
+    }
+}
diff --git a/Web/Models/Entities/Club.php b/Web/Models/Entities/Club.php
index fbdc503b..b8b8838a 100644
--- a/Web/Models/Entities/Club.php
+++ b/Web/Models/Entities/Club.php
@@ -371,36 +371,60 @@ class Club extends RowModel
     {
         return $this->getRecord()->alert;
     }
+
+    function getRealId(): int
+    {
+        return $this->getId() * -1;
+    }
+
+    function isEveryoneCanUploadAudios(): bool
+    {
+        return (bool) $this->getRecord()->everyone_can_upload_audios;
+    }
+
+    function canUploadAudio(?User $user): bool
+    {
+        if(!$user)
+            return NULL;
+
+        return $this->isEveryoneCanUploadAudios() || $this->canBeModifiedBy($user);
+    }
+
+    function getAudiosCollectionSize()
+    {
+        return (new \openvk\Web\Models\Repositories\Audios)->getClubCollectionSize($this);
+    }
     
     function toVkApiStruct(?User $user = NULL): object
     {
-        $res = [];
+        $res = (object)[];
 
         $res->id          = $this->getId();
         $res->name        = $this->getName();
         $res->screen_name = $this->getShortCode();
         $res->is_closed   = 0;
         $res->deactivated = NULL;
-        $res->is_admin    = $this->canBeModifiedBy($user);
+        $res->is_admin    = $user && $this->canBeModifiedBy($user);
 
-        if($this->canBeModifiedBy($user)) {
+        if($user && $this->canBeModifiedBy($user)) {
             $res->admin_level = 3;
         }
 
-        $res->is_member  = $this->getSubscriptionStatus($user) ? 1 : 0;
+        $res->is_member  = $user && $this->getSubscriptionStatus($user) ? 1 : 0;
 
         $res->type       = "group";
         $res->photo_50   = $this->getAvatarUrl("miniscule");
         $res->photo_100  = $this->getAvatarUrl("tiny");
         $res->photo_200  = $this->getAvatarUrl("normal");
 
-        $res->can_create_topic = $this->canBeModifiedBy($user) ? 1 : ($this->isEveryoneCanCreateTopics() ? 1 : 0);
+        $res->can_create_topic = $user && $this->canBeModifiedBy($user) ? 1 : ($this->isEveryoneCanCreateTopics() ? 1 : 0);
 
-        $res->can_post         = $this->canBeModifiedBy($user) ? 1 : ($this->canPost() ? 1 : 0);
+        $res->can_post         = $user && $this->canBeModifiedBy($user) ? 1 : ($this->canPost() ? 1 : 0);
 
-        return (object) $res;
+        return $res;
     }
 
     use Traits\TBackDrops;
     use Traits\TSubscribable;
+    use Traits\TAudioStatuses;
 }
diff --git a/Web/Models/Entities/MediaCollection.php b/Web/Models/Entities/MediaCollection.php
index 05f3835c..1f061988 100644
--- a/Web/Models/Entities/MediaCollection.php
+++ b/Web/Models/Entities/MediaCollection.php
@@ -17,7 +17,17 @@ abstract class MediaCollection extends RowModel
     
     protected $specialNames = [];
     
-    private $relations;
+    protected $relations;
+
+    /**
+     * Maximum amount of items Collection can have
+     */
+    const MAX_ITEMS = INF;
+
+    /**
+     * Maximum amount of Collections with same "owner" allowed
+     */
+    const MAX_COUNT = INF;
     
     function __construct(?ActiveRow $ar = NULL)
     {
@@ -70,18 +80,29 @@ abstract class MediaCollection extends RowModel
     }
     
     abstract function getCoverURL(): ?string;
-    
-    function fetch(int $page = 1, ?int $perPage = NULL): \Traversable
+
+    function fetchClassic(int $offset = 0, ?int $limit = NULL): \Traversable
     {
-        $related = $this->getRecord()->related("$this->relTableName.collection")->page($page, $perPage ?? OPENVK_DEFAULT_PER_PAGE)->order("media ASC");
+        $related = $this->getRecord()->related("$this->relTableName.collection")
+            ->limit($limit ?? OPENVK_DEFAULT_PER_PAGE, $offset)
+            ->order("media ASC");
+
         foreach($related as $rel) {
             $media = $rel->ref($this->entityTableName, "media");
             if(!$media)
                 continue;
-            
+
             yield new $this->entityClassName($media);
         }
     }
+
+    function fetch(int $page = 1, ?int $perPage = NULL): \Traversable
+    {
+        $page      = max(1, $page);
+        $perPage ??= OPENVK_DEFAULT_PER_PAGE;
+
+        return $this->fetchClassic($perPage * ($page - 1), $perPage);
+    }
     
     function size(): int
     {
@@ -110,7 +131,7 @@ abstract class MediaCollection extends RowModel
     {
         return $this->getRecord()->special_type !== 0;
     }
-    
+
     function add(RowModel $entity): bool
     {
         $this->entitySuitable($entity);
@@ -118,6 +139,10 @@ abstract class MediaCollection extends RowModel
         if(!$this->allowDuplicates)
             if($this->has($entity))
                 return false;
+
+        if(self::MAX_ITEMS != INF)
+            if(sizeof($this->relations->where("collection", $this->getId())) > self::MAX_ITEMS)
+                throw new \OutOfBoundsException("Collection is full");
         
         $this->relations->insert([
             "collection" => $this->getId(),
@@ -127,14 +152,14 @@ abstract class MediaCollection extends RowModel
         return true;
     }
     
-    function remove(RowModel $entity): void
+    function remove(RowModel $entity): bool
     {
         $this->entitySuitable($entity);
         
-        $this->relations->where([
+        return $this->relations->where([
             "collection" => $this->getId(),
             "media"      => $entity->getId(),
-        ])->delete();
+        ])->delete() > 0;
     }
     
     function has(RowModel $entity): bool
@@ -148,6 +173,33 @@ abstract class MediaCollection extends RowModel
         
         return !is_null($rel);
     }
-    
+
+    function save(?bool $log = false): void
+    {
+        $thisTable = DatabaseConnection::i()->getContext()->table($this->tableName);
+        if(self::MAX_COUNT != INF)
+            if(isset($this->changes["owner"]))
+                if(sizeof($thisTable->where("owner", $this->changes["owner"])) > self::MAX_COUNT)
+                    throw new \OutOfBoundsException("Maximum amount of collections");
+
+        if(is_null($this->getRecord()))
+            if(!isset($this->changes["created"]))
+                $this->stateChanges("created", time());
+        else
+            $this->stateChanges("edited", time());
+
+        parent::save($log);
+    }
+
+    function delete(bool $softly = true): void
+    {
+        if(!$softly) {
+            $this->relations->where("collection", $this->getId())
+                ->delete();
+        }
+
+        parent::delete($softly);
+    }
+
     use Traits\TOwnable;
 }
diff --git a/Web/Models/Entities/Playlist.php b/Web/Models/Entities/Playlist.php
new file mode 100644
index 00000000..c027a038
--- /dev/null
+++ b/Web/Models/Entities/Playlist.php
@@ -0,0 +1,256 @@
+<?php declare(strict_types=1);
+namespace openvk\Web\Models\Entities;
+use Chandler\Database\DatabaseConnection;
+use Nette\Database\Table\ActiveRow;
+use openvk\Web\Models\Repositories\Audios;
+use openvk\Web\Models\Repositories\Photos;
+use openvk\Web\Models\RowModel;
+use openvk\Web\Models\Entities\Photo;
+
+/**
+ * @method setName(string $name)
+ * @method setDescription(?string $desc)
+ */
+class Playlist extends MediaCollection
+{
+    protected $tableName       = "playlists";
+    protected $relTableName    = "playlist_relations";
+    protected $entityTableName = "audios";
+    protected $entityClassName = 'openvk\Web\Models\Entities\Audio';
+    protected $allowDuplicates = false;
+
+    private $importTable;
+
+    const MAX_COUNT = 1000;
+    const MAX_ITEMS = 10000;
+
+    function __construct(?ActiveRow $ar = NULL)
+    {
+        parent::__construct($ar);
+
+        $this->importTable = DatabaseConnection::i()->getContext()->table("playlist_imports");
+    }
+
+    function getCoverURL(string $size = "normal"): ?string
+    {
+        $photo = (new Photos)->get((int) $this->getRecord()->cover_photo_id);
+        return is_null($photo) ? "/assets/packages/static/openvk/img/song.jpg" : $photo->getURLBySizeId($size);
+    }
+
+    function getLength(): int
+    {
+        return $this->getRecord()->length;
+    }
+
+    function getAudios(int $offset = 0, ?int $limit = NULL, ?int $shuffleSeed = NULL): \Traversable
+    {
+        if(!$shuffleSeed) {
+            foreach ($this->fetchClassic($offset, $limit) as $e)
+                yield $e; # No, I can't return, it will break with []
+
+            return;
+        }
+
+        $ids = [];
+        foreach($this->relations->select("media AS i")->where("collection", $this->getId()) as $rel)
+            $ids[] = $rel->i;
+
+        $ids = knuth_shuffle($ids, $shuffleSeed);
+        $ids = array_slice($ids, $offset, $limit ?? OPENVK_DEFAULT_PER_PAGE);
+        foreach($ids as $id)
+            yield (new Audios)->get($id);
+    }
+
+    function add(RowModel $audio): bool
+    {
+        if($res = parent::add($audio)) {
+            $this->stateChanges("length", $this->getRecord()->length + $audio->getLength());
+            $this->save();
+        }
+
+        return $res;
+    }
+
+    function remove(RowModel $audio): bool
+    {
+        if($res = parent::remove($audio)) {
+            $this->stateChanges("length", $this->getRecord()->length - $audio->getLength());
+            $this->save();
+        }
+
+        return $res;
+    }
+
+    function isBookmarkedBy(RowModel $entity): bool
+    {
+        $id = $entity->getId();
+        if($entity instanceof Club)
+            $id *= -1;
+
+        return !is_null($this->importTable->where([
+            "entity"   => $id,
+            "playlist" => $this->getId(),
+        ])->fetch());
+    }
+
+    function bookmark(RowModel $entity): bool
+    {
+        if($this->isBookmarkedBy($entity))
+            return false;
+
+        $id = $entity->getId();
+        if($entity instanceof Club)
+            $id *= -1;
+
+        if($this->importTable->where("entity", $id)->count() > self::MAX_COUNT)
+            throw new \OutOfBoundsException("Maximum amount of playlists");
+
+        $this->importTable->insert([
+            "entity"   => $id,
+            "playlist" => $this->getId(),
+        ]);
+
+        return true;
+    }
+
+    function unbookmark(RowModel $entity): bool
+    {
+        $id = $entity->getId();
+        if($entity instanceof Club)
+            $id *= -1;
+
+        $count = $this->importTable->where([
+            "entity"   => $id,
+            "playlist" => $this->getId(),
+        ])->delete();
+
+        return $count > 0;
+    }
+    
+    function getDescription(): ?string
+    {
+        return $this->getRecord()->description;
+    }
+
+    function getDescriptionHTML(): ?string
+    {
+        return htmlspecialchars($this->getRecord()->description, ENT_DISALLOWED | ENT_XHTML);
+    }
+
+    function getListens()
+    {
+        return $this->getRecord()->listens;
+    }
+
+    function toVkApiStruct(?User $user = NULL): object
+    {
+        $oid = $this->getOwner()->getId();
+        if($this->getOwner() instanceof Club)
+            $oid *= -1;
+
+        return (object) [
+            "id"          => $this->getId(),
+            "owner_id"    => $oid,
+            "title"       => $this->getName(),
+            "description" => $this->getDescription(),
+            "size"        => $this->size(),
+            "length"      => $this->getLength(),
+            "created"     => $this->getCreationTime()->timestamp(),
+            "modified"    => $this->getEditTime() ? $this->getEditTime()->timestamp() : NULL,
+            "accessible"  => $this->canBeViewedBy($user),
+            "editable"    => $this->canBeModifiedBy($user),
+            "bookmarked"  => $this->isBookmarkedBy($user),
+            "listens"     => $this->getListens(),
+            "cover_url"   => $this->getCoverURL(),
+        ];
+    }
+
+    function setLength(): void
+    {
+        throw new \LogicException("Can't set length of playlist manually");
+    }
+
+    function resetLength(): bool
+    {
+        $this->stateChanges("length", 0);
+
+        return true;
+    }
+
+    function delete(bool $softly = true): void
+    {
+        $ctx = DatabaseConnection::i()->getContext();
+        $ctx->table("playlist_imports")->where("playlist", $this->getId())
+            ->delete();
+
+        parent::delete($softly);
+    }
+
+    function hasAudio(Audio $audio): bool
+    {
+        $ctx = DatabaseConnection::i()->getContext();
+        return !is_null($ctx->table("playlist_relations")->where([
+            "collection" => $this->getId(),
+            "media"      => $audio->getId()
+        ])->fetch());
+    }
+
+    function getCoverPhotoId(): ?int
+    {
+        return $this->getRecord()->cover_photo_id;
+    }
+
+    function canBeModifiedBy(User $user): bool
+    {
+        if(!$user)
+            return false;
+
+        if($this->getOwner() instanceof User)
+            return $user->getId() == $this->getOwner()->getId();
+        else
+            return $this->getOwner()->canBeModifiedBy($user);
+    }
+
+    function getLengthInMinutes(): int
+    {
+        return (int)round($this->getLength() / 60, PHP_ROUND_HALF_DOWN);
+    }
+
+    function fastMakeCover(int $owner, array $file)
+    {
+        $cover = new Photo;
+        $cover->setOwner($owner);
+        $cover->setDescription("Playlist cover image");
+        $cover->setFile($file);
+        $cover->setCreated(time());
+        $cover->save();
+
+        $this->setCover_photo_id($cover->getId());
+
+        return $cover;
+    }
+
+    function getURL(): string
+    {
+        return "/playlist" . $this->getOwner()->getRealId() . "_" . $this->getId();
+    }
+
+    function incrementListens()
+    {
+        $this->stateChanges("listens", ($this->getListens() + 1));
+    }
+
+    function getMetaDescription(): string
+    {
+        $length = $this->getLengthInMinutes();
+
+        $props = [];
+        $props[] = tr("audios_count", $this->size());
+        $props[] = "<span id='listensCount'>" . tr("listens_count", $this->getListens()) . "</span>";
+        if($length > 0) $props[] = tr("minutes_count", $length);
+        $props[] = tr("created_playlist") . " " . $this->getPublicationTime();
+        # if($this->getEditTime()) $props[] = tr("updated_playlist") . " " . $this->getEditTime();
+        
+        return implode(" • ", $props);
+    }
+}
diff --git a/Web/Models/Entities/Report.php b/Web/Models/Entities/Report.php
index d449a2e8..5487056f 100644
--- a/Web/Models/Entities/Report.php
+++ b/Web/Models/Entities/Report.php
@@ -5,7 +5,7 @@ use Nette\Database\Table\ActiveRow;
 use openvk\Web\Models\RowModel;
 use openvk\Web\Models\Entities\Club;
 use Chandler\Database\DatabaseConnection;
-use openvk\Web\Models\Repositories\{Applications, Comments, Notes, Reports, Users, Posts, Photos, Videos, Clubs};
+use openvk\Web\Models\Repositories\{Applications, Comments, Notes, Reports, Audios, Users, Posts, Photos, Videos, Clubs};
 use Chandler\Database\DatabaseConnection as DB;
 use Nette\InvalidStateException as ISE;
 use Nette\Database\Table\Selection;
@@ -74,6 +74,7 @@ class Report extends RowModel
         else if ($this->getContentType() == "note")    return (new Notes)->get($this->getContentId());
         else if ($this->getContentType() == "app")     return (new Applications)->get($this->getContentId());
         else if ($this->getContentType() == "user")    return (new Users)->get($this->getContentId());
+        else if ($this->getContentType() == "audio")   return (new Audios)->get($this->getContentId());
         else return null;
     }
 
diff --git a/Web/Models/Entities/Traits/TAudioStatuses.php b/Web/Models/Entities/Traits/TAudioStatuses.php
new file mode 100644
index 00000000..f957a104
--- /dev/null
+++ b/Web/Models/Entities/Traits/TAudioStatuses.php
@@ -0,0 +1,38 @@
+<?php declare(strict_types=1);
+namespace openvk\Web\Models\Entities\Traits;
+use openvk\Web\Models\Repositories\Audios;
+use Chandler\Database\DatabaseConnection;
+
+trait TAudioStatuses 
+{
+    function isBroadcastEnabled(): bool
+    {
+        if($this->getRealId() < 0) return true;
+        return (bool) $this->getRecord()->audio_broadcast_enabled;
+    }
+
+    function getCurrentAudioStatus()
+    {
+        if(!$this->isBroadcastEnabled()) return NULL;
+
+        $audioId = $this->getRecord()->last_played_track;
+
+        if(!$audioId) return NULL;
+        $audio = (new Audios)->get($audioId);
+
+        if(!$audio || $audio->isDeleted())
+            return NULL;
+
+        $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens");
+        $lastListen   = $listensTable->where([
+            "entity" => $this->getRealId(),
+            "audio"  => $audio->getId(),
+            "time >"   => (time() - $audio->getLength()) - 10,
+        ])->fetch();
+
+        if($lastListen)
+            return $audio;
+
+        return NULL;
+    }
+}
diff --git a/Web/Models/Entities/Traits/TOwnable.php b/Web/Models/Entities/Traits/TOwnable.php
index 9dc9ce2a..08e5fde3 100644
--- a/Web/Models/Entities/Traits/TOwnable.php
+++ b/Web/Models/Entities/Traits/TOwnable.php
@@ -4,6 +4,12 @@ use openvk\Web\Models\Entities\User;
 
 trait TOwnable
 {
+    function canBeViewedBy(?User $user): bool
+    {
+        // TODO implement normal check in master
+        return true;
+    }
+
     function canBeModifiedBy(User $user): bool
     {
         if(method_exists($this, "isCreatedBySystem"))
diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php
index dc227039..291dfb6a 100644
--- a/Web/Models/Entities/User.php
+++ b/Web/Models/Entities/User.php
@@ -4,7 +4,7 @@ use morphos\Gender;
 use openvk\Web\Themes\{Themepack, Themepacks};
 use openvk\Web\Util\DateTime;
 use openvk\Web\Models\RowModel;
-use openvk\Web\Models\Entities\{Photo, Message, Correspondence, Gift};
+use openvk\Web\Models\Entities\{Photo, Message, Correspondence, Gift, Audio};
 use openvk\Web\Models\Repositories\{Applications, Bans, Comments, Notes, Posts,   Users, Clubs, Albums, Gifts, Notifications, Videos, Photos};
 use openvk\Web\Models\Exceptions\InvalidUserNameException;
 use Nette\Database\Table\ActiveRow;
@@ -190,7 +190,7 @@ class User extends RowModel
     function getMorphedName(string $case = "genitive", bool $fullName = true): string
     {
         $name = $fullName ? ($this->getLastName() . " " . $this->getFirstName()) : $this->getFirstName();
-        if(!preg_match("%^[А-яё\-]+$%", $name))
+        if(!preg_match("%[А-яё\-]+$%", $name))
             return $name; # name is probably not russian
 
         $inflected = inflectName($name, $case, $this->isFemale() ? Gender::FEMALE : Gender::MALE);
@@ -455,6 +455,7 @@ class User extends RowModel
             "length"   => 1,
             "mappings" => [
                 "photos",
+                "audios",
                 "videos",
                 "messages",
                 "notes",
@@ -462,7 +463,7 @@ class User extends RowModel
                 "news",
                 "links",
                 "poster",
-                "apps"
+                "apps",
             ],
         ])->get($id);
     }
@@ -482,6 +483,7 @@ class User extends RowModel
                 "friends.add",
                 "wall.write",
                 "messages.write",
+                "audios.read",
             ],
         ])->get($id);
     }
@@ -1010,6 +1012,7 @@ class User extends RowModel
                 "friends.add",
                 "wall.write",
                 "messages.write",
+                "audios.read",
             ],
         ])->set($id, $status)->toInteger());
     }
@@ -1020,6 +1023,7 @@ class User extends RowModel
             "length"   => 1,
             "mappings" => [
                 "photos",
+                "audios",
                 "videos",
                 "messages",
                 "notes",
@@ -1027,7 +1031,7 @@ class User extends RowModel
                 "news",
                 "links",
                 "poster",
-                "apps"
+                "apps",
             ],
         ])->set($id, (int) $status)->toInteger();
 
@@ -1223,6 +1227,11 @@ class User extends RowModel
         return $response;
     }
 
+    function getRealId()
+    {
+        return $this->getId();
+    }
+
     function toVkApiStruct(): object
     {
         $res = (object) [];
@@ -1239,7 +1248,45 @@ class User extends RowModel
 
         return $res;
     }
+
+    function getAudiosCollectionSize()
+    {
+        return (new \openvk\Web\Models\Repositories\Audios)->getUserCollectionSize($this);
+    }
+
+    function getBroadcastList(string $filter = "friends", bool $shuffle = false)
+    {
+        $dbContext = DatabaseConnection::i()->getContext();
+        $entityIds = [];
+        $query     = $dbContext->table("subscriptions")->where("follower", $this->getRealId());
+
+        if($filter != "all")
+            $query = $query->where("model = ?", "openvk\\Web\\Models\\Entities\\" . ($filter == "groups" ? "Club" : "User"));
+
+        foreach($query as $_rel) {
+            $entityIds[] = $_rel->model == "openvk\\Web\\Models\\Entities\\Club" ? $_rel->target * -1 : $_rel->target;
+        }
+
+        if($shuffle) {
+            $shuffleSeed    = openssl_random_pseudo_bytes(6);
+            $shuffleSeed    = hexdec(bin2hex($shuffleSeed));
     
+            $entityIds = knuth_shuffle($entityIds, $shuffleSeed);
+        }
+
+        $returnArr = [];
+        
+        foreach($entityIds as $id) {
+            $entit = $id > 0 ? (new Users)->get($id) : (new Clubs)->get(abs($id));
+
+            if($id > 0 && $entit->isDeleted()) return;
+            $returnArr[] = $entit;
+        }
+
+        return $returnArr;
+    }
+
     use Traits\TBackDrops;
     use Traits\TSubscribable;
+    use Traits\TAudioStatuses;
 }
diff --git a/Web/Models/Entities/Video.php b/Web/Models/Entities/Video.php
index cef48e27..db520ee1 100644
--- a/Web/Models/Entities/Video.php
+++ b/Web/Models/Entities/Video.php
@@ -1,7 +1,7 @@
 <?php declare(strict_types=1);
 namespace openvk\Web\Models\Entities;
 use openvk\Web\Util\Shell\Shell;
-use openvk\Web\Util\Shell\Shell\Exceptions\{ShellUnavailableException, UnknownCommandException};
+use openvk\Web\Util\Shell\Exceptions\{ShellUnavailableException, UnknownCommandException};
 use openvk\Web\Models\VideoDrivers\VideoDriver;
 use Nette\InvalidStateException as ISE;
 
diff --git a/Web/Models/Repositories/Audios.php b/Web/Models/Repositories/Audios.php
new file mode 100644
index 00000000..ad5e822e
--- /dev/null
+++ b/Web/Models/Repositories/Audios.php
@@ -0,0 +1,296 @@
+<?php declare(strict_types=1);
+namespace openvk\Web\Models\Repositories;
+use Chandler\Database\DatabaseConnection;
+use openvk\Web\Models\Entities\Audio;
+use openvk\Web\Models\Entities\Club;
+use openvk\Web\Models\Entities\Playlist;
+use openvk\Web\Models\Entities\User;
+use openvk\Web\Models\Repositories\Util\EntityStream;
+
+class Audios
+{
+    private $context;
+    private $audios;
+    private $rels;
+    private $playlists;
+    private $playlistImports;
+    private $playlistRels;
+
+    const ORDER_NEW     = 0;
+    const ORDER_POPULAR = 1;
+
+    const VK_ORDER_NEW     = 0;
+    const VK_ORDER_LENGTH  = 1;
+    const VK_ORDER_POPULAR = 2;
+
+    function __construct()
+    {
+        $this->context = DatabaseConnection::i()->getContext();
+        $this->audios  = $this->context->table("audios");
+        $this->rels    = $this->context->table("audio_relations");
+
+        $this->playlists       = $this->context->table("playlists");
+        $this->playlistImports = $this->context->table("playlist_imports");
+        $this->playlistRels    = $this->context->table("playlist_relations");
+    }
+
+    function get(int $id): ?Audio
+    {
+        $audio = $this->audios->get($id);
+        if(!$audio)
+            return NULL;
+
+        return new Audio($audio);
+    }
+
+    function getPlaylist(int $id): ?Playlist
+    {
+        $playlist = $this->playlists->get($id);
+        if(!$playlist)
+            return NULL;
+
+        return new Playlist($playlist);
+    }
+
+    function getByOwnerAndVID(int $owner, int $vId): ?Audio
+    {
+        $audio = $this->audios->where([
+            "owner"      => $owner,
+            "virtual_id" => $vId,
+        ])->fetch();
+        if(!$audio) return NULL;
+
+        return new Audio($audio);
+    }
+
+    function getPlaylistByOwnerAndVID(int $owner, int $vId): ?Playlist
+    {
+        $playlist = $this->playlists->where([
+            "owner" => $owner,
+            "id"    => $vId,
+        ])->fetch();
+        if(!$playlist) return NULL;
+
+        return new Playlist($playlist);
+    }
+
+    function getByEntityID(int $entity, int $offset = 0, ?int $limit = NULL, ?int& $deleted = nullptr): \Traversable
+    {
+        $limit ??= OPENVK_DEFAULT_PER_PAGE;
+        $iter = $this->rels->where("entity", $entity)->limit($limit, $offset);
+        foreach($iter as $rel) {
+            $audio = $this->get($rel->audio);
+            if(!$audio || $audio->isDeleted()) {
+                $deleted++;
+                continue;
+            }
+
+            yield $audio;
+        }
+    }
+
+    function getPlaylistsByEntityId(int $entity, int $offset = 0, ?int $limit = NULL, ?int& $deleted = nullptr): \Traversable
+    {
+        $limit ??= OPENVK_DEFAULT_PER_PAGE;
+        $iter    = $this->playlistImports->where("entity", $entity)->limit($limit, $offset);
+        foreach($iter as $rel) {
+            $playlist = $this->getPlaylist($rel->playlist);
+            if(!$playlist || $playlist->isDeleted()) {
+                $deleted++;
+                continue;
+            }
+
+            yield $playlist;
+        }
+    }
+
+    function getByUser(User $user, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable
+    {
+        return $this->getByEntityID($user->getId(), ($perPage * ($page - 1)), $perPage, $deleted);
+    }
+
+    function getRandomThreeAudiosByEntityId(int $id): Array
+    {
+        $iter = $this->rels->where("entity", $id);
+        $ids = [];
+
+        foreach($iter as $it)
+            $ids[] = $it->audio;
+
+        $shuffleSeed    = openssl_random_pseudo_bytes(6);
+        $shuffleSeed    = hexdec(bin2hex($shuffleSeed));
+
+        $ids = knuth_shuffle($ids, $shuffleSeed);
+        $ids = array_slice($ids, 0, 3);
+        $audios = [];
+
+        foreach($ids as $id) {
+            $audio = $this->get((int)$id);
+
+            if(!$audio || $audio->isDeleted())
+                continue;
+
+            $audios[] = $audio;
+        }
+
+        return $audios;
+    }
+
+    function getByClub(Club $club, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable
+    {
+        return $this->getByEntityID($club->getId() * -1, ($perPage * ($page - 1)), $perPage, $deleted);
+    }
+
+    function getPlaylistsByUser(User $user, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable
+    {
+        return $this->getPlaylistsByEntityId($user->getId(), ($perPage * ($page - 1)), $perPage, $deleted);
+    }
+
+    function getPlaylistsByClub(Club $club, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable
+    {
+        return $this->getPlaylistsByEntityId($club->getId() * -1, ($perPage * ($page - 1)), $perPage, $deleted);
+    }
+    
+    function getCollectionSizeByEntityId(int $id): int
+    {
+        return sizeof($this->rels->where("entity", $id));
+    }
+
+    function getUserCollectionSize(User $user): int
+    {
+        return sizeof($this->rels->where("entity", $user->getId()));
+    }
+
+    function getClubCollectionSize(Club $club): int
+    {
+        return sizeof($this->rels->where("entity", $club->getId() * -1));
+    }
+
+    function getUserPlaylistsCount(User $user): int
+    {
+        return sizeof($this->playlistImports->where("entity", $user->getId()));
+    }
+
+    function getClubPlaylistsCount(Club $club): int
+    {
+        return sizeof($this->playlistImports->where("entity", $club->getId() * -1));
+    }
+
+    function getByUploader(User $user): EntityStream
+    {
+        $search = $this->audios->where([
+            "owner"   => $user->getId(),
+            "deleted" => 0,
+        ]);
+
+        return new EntityStream("Audio", $search);
+    }
+
+    function getGlobal(int $order, ?string $genreId = NULL): EntityStream
+    {
+        $search = $this->audios->where([
+            "deleted"   => 0,
+            "unlisted"  => 0,
+            "withdrawn" => 0,
+        ])->order($order == Audios::ORDER_NEW ? "created DESC" : "listens DESC");
+
+        if(!is_null($genreId))
+            $search = $search->where("genre", $genreId);
+
+        return new EntityStream("Audio", $search);
+    }
+
+    function search(string $query, int $sortMode = 0, bool $performerOnly = false, bool $withLyrics = false): EntityStream
+    {
+        $columns = $performerOnly ? "performer" : "performer, name";
+        $order   = (["created", "length", "listens"][$sortMode] ?? "") . " DESC";
+
+        $search = $this->audios->where([
+            "unlisted" => 0,
+            "deleted"  => 0,
+        ])->where("MATCH ($columns) AGAINST (? WITH QUERY EXPANSION)", $query)->order($order);
+
+        if($withLyrics)
+            $search = $search->where("lyrics IS NOT NULL");
+
+        return new EntityStream("Audio", $search);
+    }
+
+    function searchPlaylists(string $query): EntityStream
+    {
+        $search = $this->playlists->where([
+            "deleted" => 0,
+        ])->where("MATCH (`name`, `description`) AGAINST (? IN BOOLEAN MODE)", $query);
+
+        return new EntityStream("Playlist", $search);
+    }
+
+    function getNew(): EntityStream
+    {
+        return new EntityStream("Audio", $this->audios->where("created >= " . (time() - 259200))->where(["withdrawn" => 0, "deleted" => 0, "unlisted" => 0])->order("created DESC")->limit(25));
+    }
+
+    function getPopular(): EntityStream
+    {
+        return new EntityStream("Audio", $this->audios->where("listens > 0")->where(["withdrawn" => 0, "deleted" => 0, "unlisted" => 0])->order("listens DESC")->limit(25));
+    }
+
+    function isAdded(int $user_id, int $audio_id): bool
+    {
+        return !is_null($this->rels->where([
+            "entity" => $user_id,
+            "audio"  => $audio_id
+        ])->fetch());
+    }
+
+    function find(string $query, array $pars = [], string $sort = "id DESC", int $page = 1, ?int $perPage = NULL): \Traversable
+    {
+        $query  = "%$query%";
+        $result = $this->audios->where([
+            "unlisted" => 0,
+            "deleted"  => 0,
+        ]);
+
+        $notNullParams = [];
+
+        foreach($pars as $paramName => $paramValue)
+            if($paramName != "before" && $paramName != "after" && $paramName != "only_performers")
+                $paramValue != NULL ? $notNullParams+=["$paramName" => "%$paramValue%"]   : NULL;
+            else
+                $paramValue != NULL ? $notNullParams+=["$paramName" => "$paramValue"]     : NULL;
+        
+        $nnparamsCount = sizeof($notNullParams);
+
+        if($notNullParams["only_performers"] == "1") {
+            $result->where("performer LIKE ?", $query);
+        } else {
+            $result->where("name LIKE ? OR performer LIKE ?", $query, $query);
+        }
+
+        if($nnparamsCount > 0) {
+            foreach($notNullParams as $paramName => $paramValue) {
+                switch($paramName) {
+                    case "before":
+                        $result->where("created < ?", $paramValue);
+                        break;
+                    case "after":
+                        $result->where("created > ?", $paramValue);
+                        break;
+                    case "with_lyrics":
+                        $result->where("lyrics IS NOT NULL");
+                        break;
+                }
+            }
+        }
+
+        return new Util\EntityStream("Audio", $result->order($sort));
+    }
+
+    function findPlaylists(string $query, int $page = 1, ?int $perPage = NULL): \Traversable
+    {
+        $query  = "%$query%";
+        $result = $this->playlists->where("name LIKE ?", $query);
+
+        return new Util\EntityStream("Playlist", $result);
+    }
+}
diff --git a/Web/Models/shell/processAudio.ps1 b/Web/Models/shell/processAudio.ps1
new file mode 100644
index 00000000..206e11e1
--- /dev/null
+++ b/Web/Models/shell/processAudio.ps1
@@ -0,0 +1,39 @@
+$ovkRoot    = $args[0]
+$storageDir = $args[1]
+$fileHash   = $args[2]
+$hashPart   = $fileHash.substring(0, 2)
+$filename   = $args[3]
+$audioFile  = [System.IO.Path]::GetTempFileName()
+$temp       = [System.IO.Path]::GetTempFileName()
+
+$keyID = $args[4]
+$key   = $args[5]
+$token = $args[6]
+$seg   = $args[7]
+
+$shell = Get-WmiObject Win32_process -filter "ProcessId = $PID"
+$shell.SetPriority(16384) # because there's no "nice" program in Windows we just set a lower priority for entire tree
+
+Remove-Item $temp
+Remove-Item $audioFile
+New-Item -ItemType "directory" $temp
+New-Item -ItemType "directory" ("$temp/$fileHash" + '_fragments')
+New-Item -ItemType "directory" ("$storageDir/$hashPart/$fileHash" + '_fragments')
+Set-Location -Path $temp
+
+Move-Item $filename $audioFile
+ffmpeg -i $audioFile -f dash -encryption_scheme cenc-aes-ctr -encryption_key $key `
+    -encryption_kid $keyID -map 0:a -c:a aac -ar 44100 -seg_duration $seg `
+    -use_timeline 1 -use_template 1 -init_seg_name ($fileHash + '_fragments/0_0.$ext$') `
+    -media_seg_name ($fileHash + '_fragments/chunk$Number%06d$_$RepresentationID$.$ext$') -adaptation_sets 'id=0,streams=a' `
+    "$fileHash.mpd"
+
+ffmpeg -i $audioFile -vn -ar 44100 "original_$token.mp3"
+Move-Item "original_$token.mp3" ($fileHash + '_fragments')
+
+Get-ChildItem -Path ($fileHash + '_fragments/*') | Move-Item -Destination ("$storageDir/$hashPart/$fileHash" + '_fragments')
+Move-Item -Path ("$fileHash.mpd") -Destination "$storageDir/$hashPart"
+
+cd ..
+Remove-Item -Recurse $temp
+Remove-Item $audioFile
diff --git a/Web/Models/shell/processAudio.sh b/Web/Models/shell/processAudio.sh
new file mode 100644
index 00000000..ab5a5c55
--- /dev/null
+++ b/Web/Models/shell/processAudio.sh
@@ -0,0 +1,35 @@
+ovkRoot=$1
+storageDir=$2
+fileHash=$3
+hashPart=$(echo $fileHash | cut -c1-2)
+filename=$4
+audioFile=$(mktemp)
+temp=$(mktemp -d)
+
+keyID=$5
+key=$6
+token=$7
+seg=$8
+
+trap 'rm -f "$temp" "$audioFile"' EXIT
+
+mkdir -p "$temp/$fileHash"_fragments
+mkdir -p "$storageDir/$hashPart/$fileHash"_fragments
+cd "$temp"
+
+mv "$filename" "$audioFile"
+ffmpeg -i "$audioFile" -f dash -encryption_scheme cenc-aes-ctr -encryption_key "$key" \
+    -encryption_kid "$keyID" -map 0 -c:a aac -ar 44100 -seg_duration "$seg" \
+    -use_timeline 1 -use_template 1 -init_seg_name "$fileHash"_fragments/0_0."\$ext\$" \
+    -media_seg_name "$fileHash"_fragments/chunk"\$Number"%06d\$_"\$RepresentationID\$"."\$ext\$" -adaptation_sets 'id=0,streams=a' \
+    "$fileHash.mpd"
+
+ffmpeg -i "$audioFile" -vn -ar 44100 "original_$token.mp3"
+mv "original_$token.mp3" "$fileHash"_fragments
+
+mv "$fileHash"_fragments "$storageDir/$hashPart"
+mv "$fileHash.mpd" "$storageDir/$hashPart"
+
+cd ..
+rm -rf "$temp"
+rm -f "$audioFile"
diff --git a/Web/Presenters/AdminPresenter.php b/Web/Presenters/AdminPresenter.php
index 14fbbc74..658d2f4b 100644
--- a/Web/Presenters/AdminPresenter.php
+++ b/Web/Presenters/AdminPresenter.php
@@ -3,7 +3,19 @@ namespace openvk\Web\Presenters;
 use Chandler\Database\Log;
 use Chandler\Database\Logs;
 use openvk\Web\Models\Entities\{Voucher, Gift, GiftCategory, User, BannedLink};
-use openvk\Web\Models\Repositories\{Bans, ChandlerGroups, ChandlerUsers, Photos, Posts, Users, Clubs, Videos, Vouchers, Gifts, BannedLinks};
+use openvk\Web\Models\Repositories\{Audios,
+    ChandlerGroups,
+    ChandlerUsers,
+    Users,
+    Clubs,
+    Util\EntityStream,
+    Vouchers,
+    Gifts,
+    BannedLinks,
+    Bans,
+    Photos, 
+    Posts, 
+    Videos};
 use Chandler\Database\DatabaseConnection;
 
 final class AdminPresenter extends OpenVKPresenter
@@ -14,9 +26,10 @@ final class AdminPresenter extends OpenVKPresenter
     private $gifts;
     private $bannedLinks;
     private $chandlerGroups;
+    private $audios;
     private $logs;
 
-    function __construct(Users $users, Clubs $clubs, Vouchers $vouchers, Gifts $gifts, BannedLinks $bannedLinks, ChandlerGroups $chandlerGroups)
+    function __construct(Users $users, Clubs $clubs, Vouchers $vouchers, Gifts $gifts, BannedLinks $bannedLinks, ChandlerGroups $chandlerGroups, Audios $audios)
     {
         $this->users    = $users;
         $this->clubs    = $clubs;
@@ -24,8 +37,9 @@ final class AdminPresenter extends OpenVKPresenter
         $this->gifts    = $gifts;
         $this->bannedLinks = $bannedLinks;
         $this->chandlerGroups = $chandlerGroups;
+        $this->audios = $audios;
         $this->logs = DatabaseConnection::i()->getContext()->table("ChandlerLogs");
-        
+
         parent::__construct();
     }
     
@@ -43,6 +57,15 @@ final class AdminPresenter extends OpenVKPresenter
         $count = $repo->find($query)->size();
         return $repo->find($query)->page($page, 20);
     }
+
+    private function searchPlaylists(&$count)
+    {
+        $query = $this->queryParam("q") ?? "";
+        $page  = (int) ($this->queryParam("p") ?? 1);
+
+        $count = $this->audios->findPlaylists($query)->size();
+        return $this->audios->findPlaylists($query)->page($page, 20);
+    }
     
     function onStartup(): void
     {
@@ -578,6 +601,54 @@ final class AdminPresenter extends OpenVKPresenter
         $this->redirect("/admin/users/id" . $user->getId());
     }
 
+    function renderMusic(): void
+    {
+        $this->template->mode = in_array($this->queryParam("act"), ["audios", "playlists"]) ? $this->queryParam("act") : "audios";
+        if ($this->template->mode === "audios")
+            $this->template->audios = $this->searchResults($this->audios, $this->template->count);
+        else
+            $this->template->playlists = $this->searchPlaylists($this->template->count);
+    }
+
+    function renderEditMusic(int $audio_id): void
+    {
+        $audio = $this->audios->get($audio_id);
+        $this->template->audio = $audio;
+
+        try {
+            $this->template->owner = $audio->getOwner()->getId();
+        } catch(\Throwable $e) {
+            $this->template->owner = 1;
+        }
+
+        if ($_SERVER["REQUEST_METHOD"] === "POST") {
+            $audio->setName($this->postParam("name"));
+            $audio->setPerformer($this->postParam("performer"));
+            $audio->setLyrics($this->postParam("text"));
+            $audio->setGenre($this->postParam("genre"));
+            $audio->setOwner((int) $this->postParam("owner"));
+            $audio->setExplicit(!empty($this->postParam("explicit")));
+            $audio->setDeleted(!empty($this->postParam("deleted")));
+            $audio->setWithdrawn(!empty($this->postParam("withdrawn")));
+            $audio->save();
+        }
+    }
+
+    function renderEditPlaylist(int $playlist_id): void
+    {
+        $playlist = $this->audios->getPlaylist($playlist_id);
+        $this->template->playlist = $playlist;
+
+        if ($_SERVER["REQUEST_METHOD"] === "POST") {
+            $playlist->setName($this->postParam("name"));
+            $playlist->setDescription($this->postParam("description"));
+            $playlist->setCover_Photo_Id((int) $this->postParam("photo"));
+            $playlist->setOwner((int) $this->postParam("owner"));
+            $playlist->setDeleted(!empty($this->postParam("deleted")));
+            $playlist->save();
+        }
+    }
+
     function renderLogs(): void
     {
         $filter = [];
diff --git a/Web/Presenters/AudioPresenter.php b/Web/Presenters/AudioPresenter.php
new file mode 100644
index 00000000..3bae710c
--- /dev/null
+++ b/Web/Presenters/AudioPresenter.php
@@ -0,0 +1,696 @@
+<?php declare(strict_types=1);
+namespace openvk\Web\Presenters;
+use Chandler\Database\DatabaseConnection;
+use openvk\Web\Models\Entities\Audio;
+use openvk\Web\Models\Entities\Club;
+use openvk\Web\Models\Entities\Photo;
+use openvk\Web\Models\Entities\Playlist;
+use openvk\Web\Models\Repositories\Audios;
+use openvk\Web\Models\Repositories\Clubs;
+use openvk\Web\Models\Repositories\Users;
+
+final class AudioPresenter extends OpenVKPresenter
+{
+    private $audios;
+    protected $presenterName = "audios";
+
+    const MAX_AUDIO_SIZE = 25000000;
+
+    function __construct(Audios $audios)
+    {
+        $this->audios = $audios;
+    }
+
+    function renderPopular(): void
+    {
+        $this->renderList(NULL, "popular");
+    }
+
+    function renderNew(): void
+    {
+        $this->renderList(NULL, "new");
+    }
+
+    function renderList(?int $owner = NULL, ?string $mode = "list"): void
+    {
+        $this->template->_template = "Audio/List.xml";
+        $page = (int)($this->queryParam("p") ?? 1);
+        $audios = [];
+
+        if ($mode === "list") {
+            $entity = NULL;
+            if ($owner < 0) {
+                $entity = (new Clubs)->get($owner * -1);
+                if (!$entity || $entity->isBanned())
+                    $this->redirect("/audios" . $this->user->id);
+
+                $audios = $this->audios->getByClub($entity, $page, 10);
+                $audiosCount = $this->audios->getClubCollectionSize($entity);
+            } else {
+                $entity = (new Users)->get($owner);
+                if (!$entity || $entity->isDeleted() || $entity->isBanned())
+                    $this->redirect("/audios" . $this->user->id);
+
+                if(!$entity->getPrivacyPermission("audios.read", $this->user->identity))
+                    $this->flashFail("err", tr("forbidden"), tr("forbidden_comment"));
+
+                $audios = $this->audios->getByUser($entity, $page, 10);
+                $audiosCount = $this->audios->getUserCollectionSize($entity);
+            }
+
+            if (!$entity)
+                $this->notFound();
+
+            $this->template->owner = $entity;
+            $this->template->ownerId = $owner;
+            $this->template->club = $owner < 0 ? $entity : NULL;
+            $this->template->isMy = ($owner > 0 && ($entity->getId() === $this->user->id));
+            $this->template->isMyClub = ($owner < 0 && $entity->canBeModifiedBy($this->user->identity));
+        } else if ($mode === "new") {
+            $audios = $this->audios->getNew();
+            $audiosCount = $audios->size();
+        } else if ($mode === "playlists") {
+            if($owner < 0) {
+                $entity = (new Clubs)->get(abs($owner));
+                if (!$entity || $entity->isBanned())
+                    $this->redirect("/playlists" . $this->user->id);
+
+                $playlists = $this->audios->getPlaylistsByClub($entity, $page, 10);
+                $playlistsCount = $this->audios->getClubPlaylistsCount($entity);
+            } else {
+                $entity = (new Users)->get($owner);
+                if (!$entity || $entity->isDeleted() || $entity->isBanned())
+                    $this->redirect("/playlists" . $this->user->id);
+
+                if(!$entity->getPrivacyPermission("audios.read", $this->user->identity))
+                    $this->flashFail("err", tr("forbidden"), tr("forbidden_comment"));
+
+                $playlists = $this->audios->getPlaylistsByUser($entity, $page, 9);
+                $playlistsCount = $this->audios->getUserPlaylistsCount($entity);
+            }
+
+            $this->template->playlists = iterator_to_array($playlists);
+            $this->template->playlistsCount = $playlistsCount;
+            $this->template->owner = $entity;
+            $this->template->ownerId = $owner;
+            $this->template->club = $owner < 0 ? $entity : NULL;
+            $this->template->isMy = ($owner > 0 && ($entity->getId() === $this->user->id));
+            $this->template->isMyClub = ($owner < 0 && $entity->canBeModifiedBy($this->user->identity));
+        } else {
+            $audios = $this->audios->getPopular();
+            $audiosCount = $audios->size();
+        }
+
+        // $this->renderApp("owner=$owner");
+        if ($audios !== []) {
+            $this->template->audios = iterator_to_array($audios);
+            $this->template->audiosCount = $audiosCount;
+        }
+
+        $this->template->mode = $mode;
+        $this->template->page = $page;
+
+        if(in_array($mode, ["list", "new", "popular"]) && $this->user->identity)
+            $this->template->friendsAudios = $this->user->identity->getBroadcastList("all", true);
+    }
+
+    function renderEmbed(int $owner, int $id): void
+    {
+        $audio = $this->audios->getByOwnerAndVID($owner, $id);
+        if(!$audio) {
+            header("HTTP/1.1 404 Not Found");
+            exit("<b>" . tr("audio_embed_not_found") . ".</b>");
+        } else if($audio->isDeleted()) {
+            header("HTTP/1.1 410 Not Found");
+            exit("<b>" . tr("audio_embed_deleted") . ".</b>");
+        } else if($audio->isWithdrawn()) {
+            header("HTTP/1.1 451 Unavailable for legal reasons");
+            exit("<b>" . tr("audio_embed_withdrawn") . ".</b>");
+        } else if(!$audio->canBeViewedBy(NULL)) {
+            header("HTTP/1.1 403 Forbidden");
+            exit("<b>" . tr("audio_embed_forbidden") . ".</b>");
+        } else if(!$audio->isAvailable()) {
+            header("HTTP/1.1 425 Too Early");
+            exit("<b>" . tr("audio_embed_processing") . ".</b>");
+        }
+
+        $this->template->audio = $audio;
+    }
+
+    function renderUpload(): void
+    {
+        $this->assertUserLoggedIn();
+
+        $group = NULL;
+        $isAjax = $this->postParam("ajax", false) == 1;
+        if(!is_null($this->queryParam("gid"))) {
+            $gid   = (int) $this->queryParam("gid");
+            $group = (new Clubs)->get($gid);
+            if(!$group)
+                $this->flashFail("err", tr("forbidden"), tr("not_enough_permissions_comment"), null, $isAjax);
+
+            if(!$group->canUploadAudio($this->user->identity))
+                $this->flashFail("err", tr("forbidden"), tr("not_enough_permissions_comment"), null, $isAjax);
+        }
+
+        $this->template->group = $group;
+
+        if($_SERVER["REQUEST_METHOD"] !== "POST")
+            return;
+
+        $upload = $_FILES["blob"];
+        if(isset($upload) && file_exists($upload["tmp_name"])) {
+            if($upload["size"] > self::MAX_AUDIO_SIZE)
+                $this->flashFail("err", tr("error"), tr("media_file_corrupted_or_too_large"), null, $isAjax);
+        } else {
+            $err = !isset($upload) ? 65536 : $upload["error"];
+            $err = str_pad(dechex($err), 9, "0", STR_PAD_LEFT);
+            $readableError = tr("error_generic");
+
+            switch($upload["error"]) {
+                default:
+                case UPLOAD_ERR_INI_SIZE:
+                case UPLOAD_ERR_FORM_SIZE:
+                    $readableError = tr("file_too_big");
+                    break;
+                case UPLOAD_ERR_PARTIAL:
+                    $readableError = tr("file_loaded_partially");
+                    break;
+                case UPLOAD_ERR_NO_FILE:
+                    $readableError = tr("file_not_uploaded");
+                    break;
+                case UPLOAD_ERR_NO_TMP_DIR:
+                    $readableError = "Missing a temporary folder.";
+                    break;
+                case UPLOAD_ERR_CANT_WRITE:
+                case UPLOAD_ERR_EXTENSION:
+                    $readableError = "Failed to write file to disk. ";
+                    break;
+            }
+
+            $this->flashFail("err", tr("error"), $readableError . " " . tr("error_code", $err), null, $isAjax);
+        }
+
+        $performer = $this->postParam("performer");
+        $name      = $this->postParam("name");
+        $lyrics    = $this->postParam("lyrics");
+        $genre     = empty($this->postParam("genre")) ? "Other" : $this->postParam("genre");
+        $nsfw      = ($this->postParam("explicit") ?? "off") === "on";
+        if(empty($performer) || empty($name) || iconv_strlen($performer . $name) > 128) # FQN of audio must not be more than 128 chars
+            $this->flashFail("err", tr("error"), tr("error_insufficient_info"), null, $isAjax);
+
+        $audio = new Audio;
+        $audio->setOwner($this->user->id);
+        $audio->setName($name);
+        $audio->setPerformer($performer);
+        $audio->setLyrics(empty($lyrics) ? NULL : $lyrics);
+        $audio->setGenre($genre);
+        $audio->setExplicit($nsfw);
+
+        try {
+            $audio->setFile($upload);
+        } catch(\DomainException $ex) {
+            $e = $ex->getMessage();
+            $this->flashFail("err", tr("error"), tr("media_file_corrupted_or_too_large") . " $e.", null, $isAjax);
+        } catch(\RuntimeException $ex) {
+            $this->flashFail("err", tr("error"), tr("ffmpeg_timeout"), null, $isAjax);
+        } catch(\BadMethodCallException $ex) {
+            $this->flashFail("err", tr("error"), "Загрузка аудио под Linux на данный момент не реализована. Следите за обновлениями: <a href='https://github.com/openvk/openvk/pull/512/commits'>https://github.com/openvk/openvk/pull/512/commits</a>", null, $isAjax);
+        } catch(\Exception $ex) {
+            $this->flashFail("err", tr("error"), tr("ffmpeg_not_installed"), null, $isAjax);
+        }
+
+        $audio->save();
+        $audio->add($group ?? $this->user->identity);
+
+        if(!$isAjax)
+            $this->redirect(is_null($group) ? "/audios" . $this->user->id : "/audios-" . $group->getId());
+        else {
+            $redirectLink = "/audios";
+
+            if(!is_null($group))
+                $redirectLink .= $group->getRealId();
+            else
+                $redirectLink .= $this->user->id;
+
+            $pagesCount = (int)ceil((new Audios)->getCollectionSizeByEntityId(isset($group) ? $group->getRealId() : $this->user->id) / 10);
+            $redirectLink .= "?p=".$pagesCount;
+
+            $this->returnJson([
+                "success" => true,
+                "redirect_link" => $redirectLink,
+            ]);
+        }
+    }
+
+    function renderListen(int $id): void
+    {
+        if ($_SERVER["REQUEST_METHOD"] === "POST") {
+            $this->assertNoCSRF();
+
+            if(is_null($this->user))
+                $this->returnJson(["success" => false]);
+
+            $audio = $this->audios->get($id);
+
+            if ($audio && !$audio->isDeleted() && !$audio->isWithdrawn()) {
+                if(!empty($this->postParam("playlist"))) {
+                    $playlist = (new Audios)->getPlaylist((int)$this->postParam("playlist"));
+
+                    if(!$playlist || $playlist->isDeleted() || !$playlist->canBeViewedBy($this->user->identity) || !$playlist->hasAudio($audio))
+                        $playlist = NULL;
+                }
+
+                $listen = $audio->listen($this->user->identity, $playlist);
+
+                $returnArr = ["success" => $listen];
+                
+                if($playlist)
+                    $returnArr["new_playlists_listens"] = $playlist->getListens();
+
+                $this->returnJson($returnArr);
+            }
+
+            $this->returnJson(["success" => false]);
+        } else {
+            $this->redirect("/");
+        }
+    }
+
+    function renderSearch(): void
+    {
+        $this->redirect("/search?type=audios");
+    }
+
+    function renderNewPlaylist(): void
+    {
+        $this->assertUserLoggedIn();
+        $this->willExecuteWriteAction(true);
+
+        $owner = $this->user->id;
+
+        if ($this->requestParam("gid")) {
+            $club = (new Clubs)->get((int) abs((int)$this->requestParam("gid")));
+            if (!$club || $club->isBanned() || !$club->canBeModifiedBy($this->user->identity))
+                $this->redirect("/audios" . $this->user->id);
+
+            $owner = ($club->getId() * -1);
+
+            $this->template->club = $club;
+        }
+
+        $this->template->owner = $owner;
+
+        if ($_SERVER["REQUEST_METHOD"] === "POST") {
+            $title = $this->postParam("title");
+            $description = $this->postParam("description");
+            $audios = !empty($this->postParam("audios")) ? array_slice(explode(",", $this->postParam("audios")), 0, 100) : [];
+
+            if(empty($title) || iconv_strlen($title) < 1)
+                $this->flashFail("err", tr("error"), tr("set_playlist_name"));
+
+            $playlist = new Playlist;
+            $playlist->setOwner($owner);
+            $playlist->setName(substr($title, 0, 125));
+            $playlist->setDescription(substr($description, 0, 2045));
+
+            if($_FILES["cover"]["error"] === UPLOAD_ERR_OK) {
+                if(!str_starts_with($_FILES["cover"]["type"], "image"))
+                    $this->flashFail("err", tr("error"), tr("not_a_photo"));
+
+                try {
+                    $playlist->fastMakeCover($this->user->id, $_FILES["cover"]);
+                } catch(\Throwable $e) {
+                    $this->flashFail("err", tr("error"), tr("invalid_cover_photo"));
+                }
+            }
+
+            $playlist->save();
+
+            foreach($audios as $audio) {
+                $audio = $this->audios->get((int)$audio);
+
+                if(!$audio || $audio->isDeleted() || !$audio->canBeViewedBy($this->user->identity))
+                    continue;
+
+                $playlist->add($audio);
+            }
+
+            $playlist->bookmark(isset($club) ? $club : $this->user->identity);
+            $this->redirect("/playlist" . $owner . "_" . $playlist->getId());
+        } else {
+            if(isset($club)) {
+                $this->template->audios = iterator_to_array($this->audios->getByClub($club, 1, 10));
+                $count = (new Audios)->getClubCollectionSize($club); 
+            } else {
+                $this->template->audios = iterator_to_array($this->audios->getByUser($this->user->identity, 1, 10));
+                $count = (new Audios)->getUserCollectionSize($this->user->identity);
+            }
+
+            $this->template->pagesCount = ceil($count / 10);
+        }
+    }
+
+    function renderPlaylistAction(int $id) {
+        $this->assertUserLoggedIn();
+        $this->willExecuteWriteAction(true);
+        $this->assertNoCSRF();
+
+        if ($_SERVER["REQUEST_METHOD"] !== "POST") {
+            header("HTTP/1.1 405 Method Not Allowed");
+            $this->redirect("/");
+        }
+
+        $playlist = $this->audios->getPlaylist($id);
+
+        if(!$playlist || $playlist->isDeleted())
+            $this->flashFail("err", "error", tr("invalid_playlist"), null, true);
+
+        switch ($this->queryParam("act")) {
+            case "bookmark":
+                if(!$playlist->isBookmarkedBy($this->user->identity))
+                    $playlist->bookmark($this->user->identity);
+                else
+                    $this->flashFail("err", "error", tr("playlist_already_bookmarked"), null, true);
+
+                break;
+            case "unbookmark":
+                if($playlist->isBookmarkedBy($this->user->identity))
+                    $playlist->unbookmark($this->user->identity);
+                else
+                    $this->flashFail("err", "error", tr("playlist_not_bookmarked"), null, true);
+
+                break;
+            case "delete":
+                if($playlist->canBeModifiedBy($this->user->identity)) {
+                    $tmOwner = $playlist->getOwner();
+                    $playlist->delete();
+                } else
+                    $this->flashFail("err", "error", tr("access_denied"), null, true);
+                
+                $this->returnJson(["success" => true, "id" => $tmOwner->getRealId()]);
+                break;
+            default:
+                break;
+        }
+
+        $this->returnJson(["success" => true]);
+    }
+
+    function renderEditPlaylist(int $owner_id, int $virtual_id)
+    {
+        $this->assertUserLoggedIn();
+        $this->willExecuteWriteAction();
+
+        $playlist = $this->audios->getPlaylistByOwnerAndVID($owner_id, $virtual_id);
+        $page = (int)($this->queryParam("p") ?? 1);
+        if (!$playlist || $playlist->isDeleted() || !$playlist->canBeModifiedBy($this->user->identity))
+            $this->notFound();
+
+        $this->template->playlist = $playlist;
+        $this->template->page = $page;
+        
+        $audios = iterator_to_array($playlist->fetch(1, $playlist->size()));
+        $this->template->audios = array_slice($audios, 0, 10);
+        $audiosIds = [];
+
+        foreach($audios as $aud)
+            $audiosIds[] = $aud->getId();
+
+        $this->template->audiosIds = implode(",", array_unique($audiosIds)) . ",";
+        $this->template->ownerId = $owner_id;
+        $this->template->owner = $playlist->getOwner();
+        $this->template->pagesCount = $pagesCount = ceil($playlist->size() / 10);
+
+        if($_SERVER["REQUEST_METHOD"] !== "POST")
+            return;
+
+        $title = $this->postParam("title");
+        $description = $this->postParam("description");
+        $new_audios = !empty($this->postParam("audios")) ? explode(",", rtrim($this->postParam("audios"), ",")) : [];
+
+        if(empty($title) || iconv_strlen($title) < 1)
+            $this->flashFail("err", tr("error"), tr("set_playlist_name"));
+        
+        $playlist->setName(ovk_proc_strtr($title, 125));
+        $playlist->setDescription(ovk_proc_strtr($description, 2045));
+        $playlist->setEdited(time());
+        $playlist->resetLength();
+
+        if($_FILES["new_cover"]["error"] === UPLOAD_ERR_OK) {
+            if(!str_starts_with($_FILES["new_cover"]["type"], "image"))
+                $this->flashFail("err", tr("error"), tr("not_a_photo"));
+            
+            try {
+                $playlist->fastMakeCover($this->user->id, $_FILES["new_cover"]);
+            } catch(\Throwable $e) {
+                $this->flashFail("err", tr("error"), tr("invalid_cover_photo"));
+            }
+        }
+
+        $playlist->save();
+        
+        DatabaseConnection::i()->getContext()->table("playlist_relations")->where([
+            "collection" => $playlist->getId()
+        ])->delete();
+
+        foreach ($new_audios as $new_audio) {
+            $audio = (new Audios)->get((int)$new_audio);
+
+            if(!$audio || $audio->isDeleted())
+                continue;
+
+            $playlist->add($audio);
+        }
+
+        $this->redirect("/playlist".$playlist->getPrettyId());
+    }
+
+    function renderPlaylist(int $owner_id, int $virtual_id): void
+    {
+        $playlist = $this->audios->getPlaylistByOwnerAndVID($owner_id, $virtual_id);
+        $page = (int)($this->queryParam("p") ?? 1);
+        if (!$playlist || $playlist->isDeleted())
+            $this->notFound();
+
+        $this->template->playlist = $playlist;
+        $this->template->page = $page;
+        $this->template->audios = iterator_to_array($playlist->fetch($page, 10));
+        $this->template->ownerId = $owner_id;
+        $this->template->owner = $playlist->getOwner();
+        $this->template->isBookmarked = $playlist->isBookmarkedBy($this->user->identity);
+        $this->template->isMy = $playlist->getOwner()->getId() === $this->user->id;
+        $this->template->canEdit = $playlist->canBeModifiedBy($this->user->identity);
+    }
+
+    function renderAction(int $audio_id): void
+    {
+        $this->assertUserLoggedIn();
+        $this->willExecuteWriteAction(true);
+        $this->assertNoCSRF();
+
+        if ($_SERVER["REQUEST_METHOD"] !== "POST") {
+            header("HTTP/1.1 405 Method Not Allowed");
+            $this->redirect("/");
+        }
+
+        $audio = $this->audios->get($audio_id);
+
+        if(!$audio || $audio->isDeleted())
+            $this->flashFail("err", "error", tr("invalid_audio"), null, true);
+
+        switch ($this->queryParam("act")) {
+            case "add":
+                if($audio->isWithdrawn())
+                    $this->flashFail("err", "error", tr("invalid_audio"), null, true);
+
+                if(!$audio->isInLibraryOf($this->user->identity))
+                    $audio->add($this->user->identity);
+                else
+                    $this->flashFail("err", "error", tr("do_have_audio"), null, true);
+                
+                break;
+
+            case "remove":
+                if($audio->isInLibraryOf($this->user->identity))
+                    $audio->remove($this->user->identity);
+                else
+                    $this->flashFail("err", "error", tr("do_not_have_audio"), null, true);
+
+                break;
+            case "remove_club":
+                $club = (new Clubs)->get((int)$this->postParam("club"));
+                
+                if(!$club || !$club->canBeModifiedBy($this->user->identity))
+                    $this->flashFail("err", "error", tr("access_denied"), null, true);
+                
+                if($audio->isInLibraryOf($club))
+                    $audio->remove($club);
+                else
+                    $this->flashFail("err", "error", tr("group_hasnt_audio"), null, true);
+
+                break;
+            case "add_to_club":
+                $club = (new Clubs)->get((int)$this->postParam("club"));
+                    
+                if(!$club || !$club->canBeModifiedBy($this->user->identity))
+                    $this->flashFail("err", "error", tr("access_denied"), null, true);
+                    
+                if(!$audio->isInLibraryOf($club))
+                    $audio->add($club);
+                else
+                    $this->flashFail("err", "error", tr("group_has_audio"), null, true);
+    
+                break;
+            case "delete":
+                if($audio->canBeModifiedBy($this->user->identity))
+                    $audio->delete();
+                else
+                    $this->flashFail("err", "error", tr("access_denied"), null, true);
+
+                break;
+            case "edit":
+                $audio = $this->audios->get($audio_id);
+                if (!$audio || $audio->isDeleted() || $audio->isWithdrawn())
+                    $this->flashFail("err", "error", tr("invalid_audio"), null, true);
+
+                if ($audio->getOwner()->getId() !== $this->user->id)
+                    $this->flashFail("err", "error", tr("access_denied"), null, true);
+
+                $performer = $this->postParam("performer");
+                $name      = $this->postParam("name");
+                $lyrics    = $this->postParam("lyrics");
+                $genre     = empty($this->postParam("genre")) ? "undefined" : $this->postParam("genre");
+                $nsfw      = (int)($this->postParam("explicit") ?? 0) === 1;
+                $unlisted  = (int)($this->postParam("unlisted") ?? 0) === 1;
+                if(empty($performer) || empty($name) || iconv_strlen($performer . $name) > 128) # FQN of audio must not be more than 128 chars
+                    $this->flashFail("err", tr("error"), tr("error_insufficient_info"), null, true);
+
+                $audio->setName($name);
+                $audio->setPerformer($performer);
+                $audio->setLyrics(empty($lyrics) ? NULL : $lyrics);
+                $audio->setGenre($genre);
+                $audio->setExplicit($nsfw);
+                $audio->setSearchability($unlisted);
+                $audio->setEdited(time());
+                $audio->save();
+
+                $this->returnJson(["success" => true, "new_info" => [
+                    "name" => ovk_proc_strtr($audio->getTitle(), 40),
+                    "performer" => ovk_proc_strtr($audio->getPerformer(), 40),
+                    "lyrics" => nl2br($audio->getLyrics() ?? ""),
+                    "lyrics_unformatted" => $audio->getLyrics() ?? "",
+                    "explicit" => $audio->isExplicit(),
+                    "genre" => $audio->getGenre(),
+                    "unlisted" => $audio->isUnlisted(),
+                ]]);
+                break;
+
+            default:
+                break;
+        }
+
+        $this->returnJson(["success" => true]);
+    }
+
+    function renderPlaylists(int $owner)
+    {
+        $this->renderList($owner, "playlists");
+    }
+
+    function renderApiGetContext()
+    {
+        if ($_SERVER["REQUEST_METHOD"] !== "POST") {
+            header("HTTP/1.1 405 Method Not Allowed");
+            $this->redirect("/");
+        }
+
+        $ctx_type = $this->postParam("context");
+        $ctx_id = (int)($this->postParam("context_entity"));
+        $page = (int)($this->postParam("page") ?? 1);
+        $perPage = 10;
+
+        switch($ctx_type) {
+            default:
+            case "entity_audios":
+                if($ctx_id >= 0) {
+                    $entity = $ctx_id != 0 ? (new Users)->get($ctx_id) : $this->user->identity;
+
+                    if(!$entity || !$entity->getPrivacyPermission("audios.read", $this->user->identity))
+                        $this->flashFail("err", "Error", "Can't get queue", 80, true);
+
+                    $audios = $this->audios->getByUser($entity, $page, $perPage);
+                    $audiosCount = $this->audios->getUserCollectionSize($entity);
+                } else {
+                    $entity = (new Clubs)->get(abs($ctx_id));
+
+                    if(!$entity || $entity->isBanned())
+                        $this->flashFail("err", "Error", "Can't get queue", 80, true);
+
+                    $audios = $this->audios->getByClub($entity, $page, $perPage);
+                    $audiosCount = $this->audios->getClubCollectionSize($entity);
+                }
+                break;
+            case "new_audios":
+                $audios = $this->audios->getNew();
+                $audiosCount = $audios->size();
+                break;
+            case "popular_audios":
+                $audios = $this->audios->getPopular();
+                $audiosCount = $audios->size();
+                break;
+            case "playlist_context":
+                $playlist = $this->audios->getPlaylist($ctx_id);
+
+                if (!$playlist || $playlist->isDeleted())
+                    $this->flashFail("err", "Error", "Can't get queue", 80, true);
+
+                $audios = $playlist->fetch($page, 10);
+                $audiosCount = $playlist->size();
+                break;
+            case "search_context":
+                $stream = $this->audios->search($this->postParam("query"), 2, $this->postParam("type") === "by_performer");
+                $audios = $stream->page($page, 10);
+                $audiosCount = $stream->size();
+                break;
+        }
+
+        $pagesCount = ceil($audiosCount / $perPage);
+
+        # костылёк для получения плееров в пикере аудиозаписей
+        if((int)($this->postParam("returnPlayers")) === 1) {
+            $this->template->audios = $audios;
+            $this->template->page = $page;
+            $this->template->pagesCount = $pagesCount;
+            $this->template->count = $audiosCount;
+
+            return 0;
+        }
+
+        $audiosArr = [];
+
+        foreach($audios as $audio) {
+            $audiosArr[] = [
+                "id" => $audio->getId(),
+                "name" => $audio->getTitle(),
+                "performer" => $audio->getPerformer(),
+                "keys" => $audio->getKeys(),
+                "url" => $audio->getUrl(),
+                "length" => $audio->getLength(),
+                "available" => $audio->isAvailable(),
+                "withdrawn" => $audio->isWithdrawn(),
+            ];
+        }
+
+        $resultArr = [
+            "success" => true,
+            "page" => $page,
+            "perPage" => $perPage,
+            "pagesCount" => $pagesCount,
+            "count" => $audiosCount,
+            "items" => $audiosArr,
+        ];
+
+        $this->returnJson($resultArr);
+    }
+}
diff --git a/Web/Presenters/BlobPresenter.php b/Web/Presenters/BlobPresenter.php
index 7bb3e2be..5987281d 100644
--- a/Web/Presenters/BlobPresenter.php
+++ b/Web/Presenters/BlobPresenter.php
@@ -18,6 +18,8 @@ final class BlobPresenter extends OpenVKPresenter
     
     function renderFile(/*string*/ $dir, string $name, string $format)
     {
+        header("Access-Control-Allow-Origin: *");
+
         $dir  = $this->getDirName($dir);
         $base = realpath(OPENVK_ROOT . "/storage/$dir");
         $path = realpath(OPENVK_ROOT . "/storage/$dir/$name.$format");
@@ -37,5 +39,5 @@ final class BlobPresenter extends OpenVKPresenter
         
         readfile($path);
         exit;
-    }
+      }
 }
diff --git a/Web/Presenters/CommentPresenter.php b/Web/Presenters/CommentPresenter.php
index e005af86..86ccb126 100644
--- a/Web/Presenters/CommentPresenter.php
+++ b/Web/Presenters/CommentPresenter.php
@@ -2,7 +2,7 @@
 namespace openvk\Web\Presenters;
 use openvk\Web\Models\Entities\{Comment, Notifications\MentionNotification, Photo, Video, User, Topic, Post};
 use openvk\Web\Models\Entities\Notifications\CommentNotification;
-use openvk\Web\Models\Repositories\{Comments, Clubs, Videos, Photos};
+use openvk\Web\Models\Repositories\{Comments, Clubs, Videos, Photos, Audios};
 
 final class CommentPresenter extends OpenVKPresenter
 {
@@ -103,8 +103,27 @@ final class CommentPresenter extends OpenVKPresenter
                 }
             }
         }
+
+        $audios = [];
+
+        if(!empty($this->postParam("audios"))) {
+            $un  = rtrim($this->postParam("audios"), ",");
+            $arr = explode(",", $un);
+
+            if(sizeof($arr) < 11) {
+                foreach($arr as $dat) {
+                    $ids = explode("_", $dat);
+                    $audio = (new Audios)->getByOwnerAndVID((int)$ids[0], (int)$ids[1]);
+    
+                    if(!$audio || $audio->isDeleted())
+                        continue;
+    
+                    $audios[] = $audio;
+                }
+            }
+        }
         
-        if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1)
+        if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && sizeof($audios) < 1)
             $this->flashFail("err", tr("error_when_publishing_comment"), tr("error_comment_empty"));
         
         try {
@@ -126,6 +145,9 @@ final class CommentPresenter extends OpenVKPresenter
         if(sizeof($videos) > 0)
             foreach($videos as $vid)
                 $comment->attach($vid);
+
+        foreach($audios as $audio)
+            $comment->attach($audio);
         
         if($entity->getOwner()->getId() !== $this->user->identity->getId())
             if(($owner = $entity->getOwner()) instanceof User)
diff --git a/Web/Presenters/GroupPresenter.php b/Web/Presenters/GroupPresenter.php
index d3a46fd5..eb8f446c 100644
--- a/Web/Presenters/GroupPresenter.php
+++ b/Web/Presenters/GroupPresenter.php
@@ -3,7 +3,7 @@ namespace openvk\Web\Presenters;
 use openvk\Web\Models\Entities\{Club, Photo, Post};
 use Nette\InvalidStateException;
 use openvk\Web\Models\Entities\Notifications\ClubModeratorNotification;
-use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics};
+use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics, Audios};
 use Chandler\Security\Authenticator;
 
 final class GroupPresenter extends OpenVKPresenter
@@ -31,6 +31,8 @@ final class GroupPresenter extends OpenVKPresenter
                 $this->template->albumsCount = (new Albums)->getClubAlbumsCount($club);
                 $this->template->topics = (new Topics)->getLastTopics($club, 3);
                 $this->template->topicsCount = (new Topics)->getClubTopicsCount($club);
+                $this->template->audios      = (new Audios)->getRandomThreeAudiosByEntityId($club->getRealId());
+                $this->template->audiosCount = (new Audios)->getClubCollectionSize($club);
             }
 
             $this->template->club = $club;
@@ -218,6 +220,7 @@ final class GroupPresenter extends OpenVKPresenter
             $club->setAdministrators_List_Display(empty($this->postParam("administrators_list_display")) ? 0 : $this->postParam("administrators_list_display"));
 	    $club->setEveryone_Can_Create_Topics(empty($this->postParam("everyone_can_create_topics")) ? 0 : 1);
             $club->setDisplay_Topics_Above_Wall(empty($this->postParam("display_topics_above_wall")) ? 0 : 1);
+            $club->setEveryone_can_upload_audios(empty($this->postParam("upload_audios")) ? 0 : 1);
             $club->setHide_From_Global_Feed(empty($this->postParam("hide_from_global_feed")) ? 0 : 1);
             
             $website = $this->postParam("website") ?? "";
diff --git a/Web/Presenters/ReportPresenter.php b/Web/Presenters/ReportPresenter.php
index a87154c8..0c4cbd83 100644
--- a/Web/Presenters/ReportPresenter.php
+++ b/Web/Presenters/ReportPresenter.php
@@ -23,7 +23,7 @@ final class ReportPresenter extends OpenVKPresenter
         if ($_SERVER["REQUEST_METHOD"] === "POST")
             $this->assertNoCSRF();
 
-        $act = in_array($this->queryParam("act"), ["post", "photo", "video", "group", "comment", "note", "app", "user"]) ? $this->queryParam("act") : NULL;
+        $act = in_array($this->queryParam("act"), ["post", "photo", "video", "group", "comment", "note", "app", "user", "audio"]) ? $this->queryParam("act") : NULL;
 
         if (!$this->queryParam("orig")) {
             $this->template->reports = $this->reports->getReports(0, (int)($this->queryParam("p") ?? 1), $act, $_SERVER["REQUEST_METHOD"] !== "POST");
@@ -88,7 +88,7 @@ final class ReportPresenter extends OpenVKPresenter
         if(!$id)
             exit(json_encode([ "error" => tr("error_segmentation") ]));
 
-        if(in_array($this->queryParam("type"), ["post", "photo", "video", "group", "comment", "note", "app", "user"])) {
+        if(in_array($this->queryParam("type"), ["post", "photo", "video", "group", "comment", "note", "app", "user", "audio"])) {
             if (count(iterator_to_array($this->reports->getDuplicates($this->queryParam("type"), $id, NULL, $this->user->id))) <= 0) {
                 $report = new Report;
                 $report->setUser_id($this->user->id);
diff --git a/Web/Presenters/SearchPresenter.php b/Web/Presenters/SearchPresenter.php
index fadf9954..d80c06c3 100644
--- a/Web/Presenters/SearchPresenter.php
+++ b/Web/Presenters/SearchPresenter.php
@@ -1,7 +1,7 @@
 <?php declare(strict_types=1);
 namespace openvk\Web\Presenters;
 use openvk\Web\Models\Entities\{User, Club};
-use openvk\Web\Models\Repositories\{Users, Clubs, Posts, Comments, Videos, Applications, Notes};
+use openvk\Web\Models\Repositories\{Users, Clubs, Posts, Comments, Videos, Applications, Notes, Audios};
 use Chandler\Database\DatabaseConnection;
 
 final class SearchPresenter extends OpenVKPresenter
@@ -13,6 +13,7 @@ final class SearchPresenter extends OpenVKPresenter
     private $videos;
     private $apps;
     private $notes;
+    private $audios;
     
     function __construct(Users $users, Clubs $clubs)
     {
@@ -23,22 +24,21 @@ final class SearchPresenter extends OpenVKPresenter
         $this->videos   = new Videos;
         $this->apps     = new Applications;
         $this->notes    = new Notes;
+        $this->audios   = new Audios;
         
         parent::__construct();
     }
     
     function renderIndex(): void
     {
+        $this->assertUserLoggedIn();
+
         $query = $this->queryParam("query") ?? "";
         $type  = $this->queryParam("type") ?? "users";
         $sorter = $this->queryParam("sort") ?? "id";
         $invert = $this->queryParam("invert") == 1 ? "ASC" : "DESC";
         $page  = (int) ($this->queryParam("p") ?? 1);
         
-        $this->willExecuteWriteAction();
-        if($query != "")
-            $this->assertUserLoggedIn();
-        
         # https://youtu.be/pSAWM5YuXx8
 
         $repos = [ 
@@ -47,7 +47,7 @@ final class SearchPresenter extends OpenVKPresenter
             "posts"    => "posts",
             "comments" => "comments",
             "videos"   => "videos",
-            "audios"   => "posts",
+            "audios"   => "audios",
             "apps"     => "apps",
             "notes"    => "notes"
         ];
@@ -62,7 +62,17 @@ final class SearchPresenter extends OpenVKPresenter
                 break;   
             case "rating":
                 $sort = "rating " . $invert;
-                break;   
+                break;
+            case "length":
+                if($type != "audios") break;
+                
+                $sort = "length " . $invert;
+                break;
+            case "listens":
+                if($type != "audios") break;
+    
+                $sort = "listens " . $invert;
+                break;  
         }
 
         $parameters = [
@@ -86,18 +96,21 @@ final class SearchPresenter extends OpenVKPresenter
             "hometown"      => $this->queryParam("hometown")   != "" ? $this->queryParam("hometown") : NULL,
             "before"        => $this->queryParam("datebefore") != "" ? strtotime($this->queryParam("datebefore")) : NULL,
             "after"         => $this->queryParam("dateafter")  != "" ? strtotime($this->queryParam("dateafter")) : NULL,
-            "gender"        => $this->queryParam("gender")     != "" && $this->queryParam("gender") != 2 ? $this->queryParam("gender") : NULL
+            "gender"        => $this->queryParam("gender")     != "" && $this->queryParam("gender") != 2 ? $this->queryParam("gender") : NULL,
+            "only_performers" => $this->queryParam("only_performers") == "on" ? "1" : NULL,
+            "with_lyrics" => $this->queryParam("with_lyrics") == "on" ? true : NULL,
         ];
 
         $repo  = $repos[$type] or $this->throwError(400, "Bad Request", "Invalid search entity $type.");
         
         $results  = $this->{$repo}->find($query, $parameters, $sort);
-        $iterator = $results->page($page);
+        $iterator = $results->page($page, 14);
         $count    = $results->size();
         
         $this->template->iterator = iterator_to_array($iterator);
         $this->template->count    = $count;
         $this->template->type     = $type;
         $this->template->page     = $page;
+        $this->template->perPage  = 14;
     }
 }
diff --git a/Web/Presenters/UserPresenter.php b/Web/Presenters/UserPresenter.php
index e645f888..56d5685b 100644
--- a/Web/Presenters/UserPresenter.php
+++ b/Web/Presenters/UserPresenter.php
@@ -5,7 +5,7 @@ use openvk\Web\Util\Sms;
 use openvk\Web\Themes\Themepacks;
 use openvk\Web\Models\Entities\{Photo, Post, EmailChangeVerification};
 use openvk\Web\Models\Entities\Notifications\{CoinsTransferNotification, RatingUpNotification};
-use openvk\Web\Models\Repositories\{Users, Clubs, Albums, Videos, Notes, Vouchers, EmailChangeVerifications};
+use openvk\Web\Models\Repositories\{Users, Clubs, Albums, Videos, Notes, Vouchers, EmailChangeVerifications, Audios};
 use openvk\Web\Models\Exceptions\InvalidUserNameException;
 use openvk\Web\Util\Validator;
 use Chandler\Security\Authenticator;
@@ -45,7 +45,10 @@ final class UserPresenter extends OpenVKPresenter
             $this->template->videosCount = (new Videos)->getUserVideosCount($user);
             $this->template->notes       = (new Notes)->getUserNotes($user, 1, 4);
             $this->template->notesCount  = (new Notes)->getUserNotesCount($user);
-            
+            $this->template->audios      = (new Audios)->getRandomThreeAudiosByEntityId($user->getId());
+            $this->template->audiosCount = (new Audios)->getUserCollectionSize($user);
+            $this->template->audioStatus = $user->getCurrentAudioStatus();
+
             $this->template->user = $user;
         }
     }
@@ -169,6 +172,7 @@ final class UserPresenter extends OpenVKPresenter
                 
                 if ($this->postParam("gender") <= 1 && $this->postParam("gender") >= 0)
                 $user->setSex($this->postParam("gender"));
+                $user->setAudio_broadcast_enabled($this->checkbox("broadcast_music"));
                 
                 if(!empty($this->postParam("phone")) && $this->postParam("phone") !== $user->getPhone()) {
                     if(!OPENVK_ROOT_CONF["openvk"]["credentials"]["smsc"]["enable"])
@@ -241,6 +245,7 @@ final class UserPresenter extends OpenVKPresenter
                 }
 
                 $user->setStatus(empty($this->postParam("status")) ? NULL : $this->postParam("status"));
+                $user->setAudio_broadcast_enabled($this->postParam("broadcast") == 1);
                 $user->save();
 
                 $this->returnJson([
@@ -430,6 +435,7 @@ final class UserPresenter extends OpenVKPresenter
                     "friends.add",
                     "wall.write",
                     "messages.write",
+                    "audios.read",
                 ];
                 foreach($settings as $setting) {
                     $input = $this->postParam(str_replace(".", "_", $setting));
@@ -474,6 +480,7 @@ final class UserPresenter extends OpenVKPresenter
             } else if($_GET['act'] === "lMenu") {
                 $settings = [
                     "menu_bildoj"    => "photos",
+                    "menu_muziko"    => "audios",
                     "menu_filmetoj"  => "videos",
                     "menu_mesagoj"   => "messages",
                     "menu_notatoj"   => "notes",
diff --git a/Web/Presenters/WallPresenter.php b/Web/Presenters/WallPresenter.php
index d89b722a..a01c8262 100644
--- a/Web/Presenters/WallPresenter.php
+++ b/Web/Presenters/WallPresenter.php
@@ -3,7 +3,7 @@ namespace openvk\Web\Presenters;
 use openvk\Web\Models\Exceptions\TooMuchOptionsException;
 use openvk\Web\Models\Entities\{Poll, Post, Photo, Video, Club, User};
 use openvk\Web\Models\Entities\Notifications\{MentionNotification, RepostNotification, WallPostNotification};
-use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums, Notes, Videos, Comments, Photos};
+use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums, Notes, Videos, Comments, Photos, Audios};
 use Chandler\Database\DatabaseConnection;
 use Nette\InvalidStateException as ISE;
 use Bhaktaraz\RSSGenerator\Item;
@@ -311,8 +311,27 @@ final class WallPresenter extends OpenVKPresenter
                 }
             }
         }
+
+        $audios = [];
+
+        if(!empty($this->postParam("audios"))) {
+            $un  = rtrim($this->postParam("audios"), ",");
+            $arr = explode(",", $un);
+
+            if(sizeof($arr) < 11) {
+                foreach($arr as $dat) {
+                    $ids = explode("_", $dat);
+                    $audio = (new Audios)->getByOwnerAndVID((int)$ids[0], (int)$ids[1]);
+    
+                    if(!$audio || $audio->isDeleted())
+                        continue;
+    
+                    $audios[] = $audio;
+                }
+            }
+        }
         
-        if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && !$poll && !$note)
+        if(empty($this->postParam("text")) && sizeof($photos) < 1 && sizeof($videos) < 1 && sizeof($audios) < 1 && !$poll && !$note)
             $this->flashFail("err", tr("failed_to_publish_post"), tr("post_is_empty_or_too_big"));
         
         try {
@@ -341,6 +360,9 @@ final class WallPresenter extends OpenVKPresenter
 
         if(!is_null($note))
             $post->attach($note);
+
+        foreach($audios as $audio)
+        	$post->attach($audio);
         
         if($wall > 0 && $wall !== $this->user->identity->getId())
             (new WallPostNotification($wallOwner, $post, $this->user->identity))->emit();
diff --git a/Web/Presenters/templates/@layout.xml b/Web/Presenters/templates/@layout.xml
index f8a975e0..31e0bc5c 100644
--- a/Web/Presenters/templates/@layout.xml
+++ b/Web/Presenters/templates/@layout.xml
@@ -16,6 +16,8 @@
         {script "js/node_modules/umbrellajs/umbrella.min.js"}
         {script "js/l10n.js"}
         {script "js/openvk.cls.js"}
+        {script "js/node_modules/dashjs/dist/dash.all.min.js"}
+        {script "js/al_music.js"}
 
         {css "js/node_modules/tippy.js/dist/backdrop.css"}
         {css "js/node_modules/tippy.js/dist/border.css"}
@@ -122,6 +124,7 @@
                                             <option value="comments">{_s_by_comments}</option>
                                             <option value="videos">{_s_by_videos}</option>
                                             <option value="apps">{_s_by_apps}</option>
+                                            <option value="audios">{_s_by_audios}</option>
                                         </select>
                                     </form>
                                     <div class="searchTips" id="srcht" hidden>
@@ -140,13 +143,13 @@
                                             <option value="comments" {if str_contains($_SERVER['REQUEST_URI'], "type=comments")}selected{/if}>{_s_by_comments}</option>
                                             <option value="videos"   {if str_contains($_SERVER['REQUEST_URI'], "type=videos")}selected{/if}>{_s_by_videos}</option>
                                             <option value="apps"     {if str_contains($_SERVER['REQUEST_URI'], "type=apps")}selected{/if}>{_s_by_apps}</option>
+                                            <option value="audios"   {if str_contains($_SERVER['REQUEST_URI'], "type=audios")}selected{/if}>{_s_by_audios}</option>
                                         </select>
                                         <button class="searchBtn"><span style="color:white;font-weight: 600;font-size:12px;">{_header_search}</span></button>
                                     </form>
                                     <script>
                                         let els = document.querySelectorAll("div.dec")
-                                        for(const element of els)
-                                        {
+                                        for(const element of els) {
                                             element.style.display = "none"
                                         }
                                     </script>
@@ -182,6 +185,7 @@
                             </a>
                             <a n:if="$thisUser->getLeftMenuItemStatus('photos')" href="/albums{$thisUser->getId()}" class="link">{_my_photos}</a>
                             <a n:if="$thisUser->getLeftMenuItemStatus('videos')" href="/videos{$thisUser->getId()}" class="link">{_my_videos}</a>
+                            <a n:if="$thisUser->getLeftMenuItemStatus('audios')" href="/audios{$thisUser->getId()}" class="link">{_my_audios}</a>
                             <a n:if="$thisUser->getLeftMenuItemStatus('messages')" href="/im" class="link">{_my_messages}
                                 <object type="internal/link" n:if="$thisUser->getUnreadMessagesCount() > 0">
                                     (<b>{$thisUser->getUnreadMessagesCount()}</b>)
@@ -426,6 +430,12 @@
             //]]>
         </script>
 
+        <script>
+            window.openvk = {
+                "audio_genres": {\openvk\Web\Models\Entities\Audio::genres}
+            }
+        </script>
+
         {ifset bodyScripts}
             {include bodyScripts}
         {/ifset}
diff --git a/Web/Presenters/templates/About/Tour.xml b/Web/Presenters/templates/About/Tour.xml
index cc484c66..0c02b1ca 100755
--- a/Web/Presenters/templates/About/Tour.xml
+++ b/Web/Presenters/templates/About/Tour.xml
@@ -178,11 +178,29 @@
     <h2>{_tour_section_6_title_1|noescape}</h2>
         
      <ul class="listing">
-     <li><span>{_tour_section_6_text_1|noescape}</span></li> 
+        <li><span>{_tour_section_6_text_1|noescape}</span></li> 
+        <li><span>{_tour_section_6_text_2|noescape}</span></li> 
+        <li><span>{_tour_section_6_text_3|noescape}</span></li> 
+        <img src="assets/packages/static/openvk/img/tour/audios.png" width="440">
      </ul>
+
+    <ul class="listing">
+        <li><span>{_tour_section_6_text_4|noescape}</span></li> 
+        <img src="assets/packages/static/openvk/img/tour/audios_search.png" width="440">
+        <li><span>{_tour_section_6_text_5|noescape}</span></li> 
+        <img src="assets/packages/static/openvk/img/tour/audios_upload.png" width="440">
+    </ul>
      
-    
-                          
+    <p class="big">{_tour_section_6_bottom_text_1|noescape}</p>
+
+    <h2>{_tour_section_6_title_2|noescape}</h2>        
+
+    <ul class="listing">
+        <li><span>{_tour_section_6_text_6|noescape}</span></li> 
+        <li><span>{_tour_section_6_text_7|noescape}</span></li> 
+        <img src="assets/packages/static/openvk/img/tour/audios_playlists.png" width="440">
+    </ul>
+
      <br>       
     </div>
     
diff --git a/Web/Presenters/templates/Admin/@layout.xml b/Web/Presenters/templates/Admin/@layout.xml
index f254dd9a..055bd0a0 100644
--- a/Web/Presenters/templates/Admin/@layout.xml
+++ b/Web/Presenters/templates/Admin/@layout.xml
@@ -97,6 +97,9 @@
                                         <li>
                                             <a href="/admin/bannedLinks">{_admin_banned_links}</a>
                                         </li>
+                                        <li>
+                                            <a href="/admin/music">{_admin_music}</a>
+                                        </li>
                                     </ul>
                                     <div class="aui-nav-heading">
                                         <strong>Chandler</strong>
diff --git a/Web/Presenters/templates/Admin/EditMusic.xml b/Web/Presenters/templates/Admin/EditMusic.xml
new file mode 100644
index 00000000..c2584760
--- /dev/null
+++ b/Web/Presenters/templates/Admin/EditMusic.xml
@@ -0,0 +1,81 @@
+{extends "@layout.xml"}
+
+{block title}
+    {_edit} {$audio->getName()}
+{/block}
+
+{block heading}
+    {$audio->getName()}
+{/block}
+
+{block content}
+    <div class="aui-tabs horizontal-tabs">
+        <form class="aui" method="POST">
+            <div class="field-group">
+                <label for="id">ID</label>
+                <input class="text medium-field" type="number" id="id" disabled value="{$audio->getId()}" />
+            </div>
+            <div class="field-group">
+                <label>{_created}</label>
+                {$audio->getPublicationTime()}
+            </div>
+            <div class="field-group">
+                <label>{_edited}</label>
+                {$audio->getEditTime() ?? "never"}
+            </div>
+            <div class="field-group">
+                <label for="name">{_name}</label>
+                <input class="text medium-field" type="text" id="name" name="name" value="{$audio->getTitle()}" />
+            </div>
+            <div class="field-group">
+                <label for="performer">{_performer}</label>
+                <input class="text medium-field" type="text" id="performer" name="performer" value="{$audio->getPerformer()}" />
+            </div>
+            <div class="field-group">
+                <label for="ext">{_lyrics}</label>
+                <textarea class="text medium-field" type="text" id="text" name="text" style="resize: vertical;">{$audio->getLyrics()}</textarea>
+            </div>
+            <div class="field-group">
+                <label>{_admin_audio_length}</label>
+                {$audio->getFormattedLength()}
+            </div>
+            <div class="field-group">
+                <label for="ext">{_genre}</label>
+                <select class="select medium-field" name="genre">
+                    <option n:foreach='\openvk\Web\Models\Entities\Audio::genres as $genre'
+                            n:attr="selected: $genre == $audio->getGenre()" value="{$genre}">
+                        {$genre}
+                    </option>
+                </select>
+            </div>
+            <div class="field-group">
+                <label>{_admin_original_file}</label>
+                <audio controls src="{$audio->getOriginalURL(true)}">
+            </div>
+            <hr />
+            <div class="field-group">
+                <label for="owner">{_owner}</label>
+                <input class="text medium-field" type="number" id="owner_id" name="owner" value="{$owner}" />
+            </div>
+            <div class="field-group">
+                <label for="explicit">Explicit</label>
+                <input class="toggle-large" type="checkbox" id="explicit" name="explicit" value="1" {if $audio->isExplicit()} checked {/if} />
+            </div>
+            <div class="field-group">
+                <label for="deleted">{_deleted}</label>
+                <input class="toggle-large" type="checkbox" id="deleted" name="deleted" value="1" {if $audio->isDeleted()} checked {/if} />
+            </div>
+            <div class="field-group">
+                <label for="withdrawn">{_withdrawn}</label>
+                <input class="toggle-large" type="checkbox" id="withdrawn" name="withdrawn" value="1" {if $audio->isWithdrawn()} checked {/if} />
+            </div>
+            <hr />
+            <div class="buttons-container">
+                <div class="buttons">
+                    <input type="hidden" name="hash" value="{$csrfToken}" />
+                    <input class="button submit" type="submit" value="{_save}">
+                </div>
+            </div>
+        </form>
+    </div>
+{/block}
diff --git a/Web/Presenters/templates/Admin/EditPlaylist.xml b/Web/Presenters/templates/Admin/EditPlaylist.xml
new file mode 100644
index 00000000..b0bd823f
--- /dev/null
+++ b/Web/Presenters/templates/Admin/EditPlaylist.xml
@@ -0,0 +1,54 @@
+{extends "@layout.xml"}
+
+{block title}
+    {_edit} {$playlist->getName()}
+{/block}
+
+{block heading}
+    {$playlist->getName()}
+{/block}
+
+{block content}
+    <div class="aui-tabs horizontal-tabs">
+        <form class="aui" method="POST">
+            <div class="field-group">
+                <label for="id">ID</label>
+                <input class="text medium-field" type="number" id="id" disabled value="{$playlist->getId()}" />
+            </div>
+            <div class="field-group">
+                <label for="name">{_name}</label>
+                <input class="text medium-field" type="text" id="name" name="name" value="{$playlist->getName()}" />
+            </div>
+            <div class="field-group">
+                <label for="ext">{_description}</label>
+                <textarea class="text medium-field" type="text" id="description" name="description" style="resize: vertical;">{$playlist->getDescription()}</textarea>
+            </div>
+            <div class="field-group">
+                <label for="ext">{_admin_cover_id}</label>
+                <span id="avatar" class="aui-avatar aui-avatar-project aui-avatar-xlarge">
+                    <span class="aui-avatar-inner">
+                        <img src="{$playlist->getCoverUrl()}" style="object-fit: cover;"></img>
+                    </span>
+                </span>
+                <br />
+                <input class="text medium-field" type="number" id="photo" name="photo" value="{$playlist->getCoverPhotoId()}" />
+            </div>
+            <hr />
+            <div class="field-group">
+                <label for="owner">{_owner}</label>
+                <input class="text medium-field" type="number" id="owner_id" name="owner" value="{$playlist->getOwner()->getId()}" />
+            </div>
+            <div class="field-group">
+                <label for="deleted">{_deleted}</label>
+                <input class="toggle-large" type="checkbox" id="deleted" name="deleted" value="1" {if $playlist->isDeleted()} checked {/if} />
+            </div>
+            <hr />
+            <div class="buttons-container">
+                <div class="buttons">
+                    <input type="hidden" name="hash" value="{$csrfToken}" />
+                    <input class="button submit" type="submit" value="{_save}">
+                </div>
+            </div>
+        </form>
+    </div>
+{/block}
diff --git a/Web/Presenters/templates/Admin/Music.xml b/Web/Presenters/templates/Admin/Music.xml
new file mode 100644
index 00000000..448ee54d
--- /dev/null
+++ b/Web/Presenters/templates/Admin/Music.xml
@@ -0,0 +1,135 @@
+{extends "@layout.xml"}
+{var $search = $mode === "audios"}
+
+{block title}
+    {_audios}
+{/block}
+
+{block heading}
+    {_audios}
+{/block}
+
+{block searchTitle}
+    {include title}
+{/block}
+
+{block content}
+    <nav class="aui-navgroup aui-navgroup-horizontal">
+        <div class="aui-navgroup-inner">
+            <div class="aui-navgroup-primary">
+                <ul class="aui-nav" resolved="">
+                    <li n:attr="class => $mode === 'audios' ? 'aui-nav-selected' : ''">
+                        <a href="?act=audios">{_audios}</a>
+                    </li>
+                    <li n:attr="class => $mode === 'playlists' ? 'aui-nav-selected' : ''">
+                        <a href="?act=playlists">{_playlists}</a>
+                    </li>
+                </ul>
+            </div>
+        </div>
+    </nav>
+
+    <table class="aui aui-table-list">
+        {if $mode === "audios"}
+            {var $audios  = iterator_to_array($audios)}
+            {var $amount = sizeof($audios)}
+            <thead>
+                <tr>
+                    <th>ID</th>
+                    <th>{_admin_author}</th>
+                    <th>{_peformer}</th>
+                    <th>{_admin_title}</th>
+                    <th>{_genre}</th>
+                    <th>Explicit</th>
+                    <th>{_withdrawn}</th>
+                    <th>{_deleted}</th>
+                    <th>{_created}</th>
+                    <th>{_actions}</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr n:foreach="$audios as $audio">
+                    <td>{$audio->getId()}</td>
+                    <td>
+                        {var $owner = $audio->getOwner()}
+                        <span class="aui-avatar aui-avatar-xsmall">
+                            <span class="aui-avatar-inner">
+                                <img src="{$owner->getAvatarUrl('miniscule')}" alt="{$owner->getCanonicalName()}" style="object-fit: cover;" role="presentation" />
+                            </span>
+                        </span>
+
+                        <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a>
+
+                        <span n:if="$owner->isBanned()" class="aui-lozenge aui-lozenge-subtle aui-lozenge-removed">{_admin_banned}</span>
+                    </td>
+                    <td>{$audio->getPerformer()}</td>
+                    <td>{$audio->getTitle()}</td>
+                    <td>{$audio->getGenre()}</td>
+                    <td>{$audio->isExplicit() ? tr("yes") : tr("no")}</td>
+                    <td n:attr="style => $audio->isWithdrawn() ? 'color: red;' : ''">
+                        {$audio->isWithdrawn() ? tr("yes") : tr("no")}
+                    </td>
+                    <td n:attr="style => $audio->isDeleted() ? 'color: red;' : ''">
+                        {$audio->isDeleted() ? tr("yes") : tr("no")}
+                    </td>
+                    <td>{$audio->getPublicationTime()}</td>
+                    <td>
+                        <a class="aui-button aui-button-primary" href="/admin/music/{$audio->getId()}/edit">
+                            <span class="aui-icon aui-icon-small aui-iconfont-new-edit">Редактировать</span>
+                        </a>
+                    </td>
+                </tr>
+            </tbody>
+        {else}
+            {var $playlists  = iterator_to_array($playlists)}
+            {var $amount     = sizeof($playlists)}
+            <thead>
+                <tr>
+                    <th>ID</th>
+                    <th>{_admin_author}</th>
+                    <th>{_name}</th>
+                    <th>{_created_playlist}</th>
+                    <th>{_actions}</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr n:foreach="$playlists as $playlist">
+                    <td>{$playlist->getId()}</td>
+                    <td>
+                        {var $owner = $playlist->getOwner()}
+                        <span class="aui-avatar aui-avatar-xsmall">
+                            <span class="aui-avatar-inner">
+                                <img src="{$owner->getAvatarUrl('miniscule')}" alt="{$owner->getCanonicalName()}" style="object-fit: cover;" role="presentation" />
+                            </span>
+                        </span>
+
+                        <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a>
+
+                        <span n:if="$owner->isBanned()" class="aui-lozenge aui-lozenge-subtle aui-lozenge-removed">{_admin_banned}</span>
+                    </td>
+                    <td>
+                        <span class="aui-avatar aui-avatar-xsmall">
+                            <span class="aui-avatar-inner">
+                                <img src="{$playlist->getCoverURL()}" alt="{$owner->getCanonicalName()}" style="object-fit: cover;" role="presentation" />
+                            </span>
+                        </span>
+                        {ovk_proc_strtr($playlist->getName(), 30)}
+                    </td>
+                    <td>{$playlist->getCreationTime()}</td>
+                    <td>
+                        <a class="aui-button aui-button-primary" href="/admin/playlist/{$playlist->getId()}/edit">
+                            <span class="aui-icon aui-icon-small aui-iconfont-new-edit">{_edit}</span>
+                        </a>
+                    </td>
+                </tr>
+            </tbody>
+        {/if}
+    </table>
+    <br/>
+    <div align="right">
+        {var $isLast = ((10 * (($_GET['p'] ?? 1) - 1)) + $amount) < $count}
+
+        <a n:if="($_GET['p'] ?? 1) > 1" class="aui-button" href="/admin/music?act={($_GET['act'] ?? 'audios')}&p={($_GET['p'] ?? 1) - 1}">&laquo;</a>
+        <a n:if="$isLast" class="aui-button" href="/admin/music?act={($_GET['act'] ?? 'audios')}&p={($_GET['p'] ?? 1) + 1}">&raquo;</a>
+    </div>
+{/block}
diff --git a/Web/Presenters/templates/Audio/ApiGetContext.xml b/Web/Presenters/templates/Audio/ApiGetContext.xml
new file mode 100644
index 00000000..77b99990
--- /dev/null
+++ b/Web/Presenters/templates/Audio/ApiGetContext.xml
@@ -0,0 +1,7 @@
+<input type="hidden" name="count" value="{$count}">
+<input type="hidden" name="pagesCount" value="{$pagesCount}">
+<input type="hidden" name="page" value="{$page}">
+
+{foreach $audios as $audio} 
+    {include "player.xml", audio => $audio, hideButtons => true}
+{/foreach}
diff --git a/Web/Presenters/templates/Audio/EditPlaylist.xml b/Web/Presenters/templates/Audio/EditPlaylist.xml
new file mode 100644
index 00000000..50dfcc2a
--- /dev/null
+++ b/Web/Presenters/templates/Audio/EditPlaylist.xml
@@ -0,0 +1,95 @@
+{extends "../@layout.xml"}
+
+{block title}{_edit_playlist}{/block}
+
+{block header}
+    <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a>
+    »
+    <a href="/audios{$ownerId}">{_audios}</a>
+    »
+    <a href="/playlist{$playlist->getPrettyId()}">{_playlist}</a>
+    »
+    {_edit_playlist}
+{/block}
+
+{block content}
+    <div class="playlistBlock" style="display: flex;margin-top: 0px;">
+        <div class="playlistCover">
+            <a>
+                <img src="{$playlist->getCoverURL('normal')}" alt="{_playlist_cover}">
+            </a>
+
+            <div class="profile_links" style="width: 139px;">
+                <a class="profile_link" style="width: 98%;" id="_deletePlaylist" data-id="{$playlist->getId()}">{_delete_playlist}</a>
+            </div>
+        </div>
+
+        <div style="padding-left: 13px;width:75%">
+            <div class="playlistInfo">
+                <input value="{$playlist->getName()}" type="text" name="title" maxlength="125">
+            </div>
+
+            <div class="moreInfo">
+                <textarea name="description" maxlength="2045" style="margin-top: 11px;">{$playlist->getDescription()}</textarea>
+            </div>
+        </div>
+    </div>
+
+    <div style="margin-top: 19px;">
+        <input id="playlist_query" type="text" style="height: 26px;" placeholder="{_header_search}">
+        <div class="playlistAudiosContainer editContainer">
+            <div id="newPlaylistAudios" n:foreach="$audios as $audio">
+                <div class="playerContainer">
+                    {include "player.xml", audio => $audio, hideButtons => true}
+                </div>
+                <div class="attachAudio addToPlaylist" data-id="{$audio->getId()}">
+                    <span>{_remove_from_playlist}</span>
+                </div>
+            </div>
+        </div>
+
+        <div class="showMoreAudiosPlaylist" data-page="2" data-playlist="{$playlist->getId()}" n:if="$pagesCount > 1">
+            {_show_more_audios}
+        </div>
+    </div>
+
+    <form method="post" id="editPlaylistForm" data-id="{$playlist->getId()}" enctype="multipart/form-data">
+        <input type="hidden" name="title" maxlength="128" />
+        <input type="hidden" name="hash" value="{$csrfToken}" />
+        <textarea style="display:none;" name="description" maxlength="2048" />
+        <input type="hidden" name="audios">
+        <input type="file" style="display:none;" name="new_cover" accept=".jpg,.png">
+
+        <div style="float:right;margin-top: 8px;">
+            <button class="button" type="submit">{_save}</button>
+        </div>
+    </form>
+
+    <script>
+        document.querySelector("input[name='audios']").value = {$audiosIds}
+
+        u("#editPlaylistForm").on("submit", (e) => {
+            document.querySelector("#editPlaylistForm input[name='title']").value = document.querySelector(".playlistInfo input[name='title']").value
+            document.querySelector("#editPlaylistForm textarea[name='description']").value = document.querySelector(".playlistBlock textarea[name='description']").value
+        })
+
+        u("#editPlaylistForm input[name='new_cover']").on("change", (e) => {
+            if(!e.currentTarget.files[0].type.startsWith("image/")) {
+                fastError(tr("not_a_photo"))
+                return
+            }
+            
+            let image = URL.createObjectURL(e.currentTarget.files[0])
+
+            document.querySelector(".playlistCover img").src = image
+        })
+
+        u(".playlistCover img").on("click", (e) => {
+            document.querySelector("input[name='new_cover']").click()
+        })
+
+        document.querySelector("#editPlaylistForm input[name='new_cover']").value = ""
+    </script>
+
+    {script "js/al_playlists.js"}
+{/block}
diff --git a/Web/Presenters/templates/Audio/Embed.xml b/Web/Presenters/templates/Audio/Embed.xml
new file mode 100644
index 00000000..b7540358
--- /dev/null
+++ b/Web/Presenters/templates/Audio/Embed.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+    <head>
+        <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
+        <link rel="icon">
+        <title>{$audio->getName()}</title>
+        
+        {css "css/main.css"}
+        {css "css/audios.css"}
+        {script "js/node_modules/dashjs/dist/dash.all.min.js"}
+        {script "js/node_modules/jquery/dist/jquery.min.js"}
+        {script "js/node_modules/umbrellajs/umbrella.min.js"}
+    </head>
+    <body id="audioEmbed">
+        {include "player.xml", audio => $audio}
+
+        {script "js/al_music.js"}
+    </body>
+</html>
diff --git a/Web/Presenters/templates/Audio/List.xml b/Web/Presenters/templates/Audio/List.xml
new file mode 100644
index 00000000..ec24f3b3
--- /dev/null
+++ b/Web/Presenters/templates/Audio/List.xml
@@ -0,0 +1,126 @@
+{extends "../@layout.xml"}
+
+{block title}
+    {if $mode == 'list'}
+        {if $ownerId > 0}
+            {_audios} {$owner->getMorphedName("genitive", false)}
+        {else}
+            {_audios_group}
+        {/if}
+    {elseif $mode == 'new'}
+        {_audio_new}
+    {elseif $mode == 'popular'}
+        {_audio_popular}
+    {else}
+        {if $ownerId > 0}
+            {_playlists} {$owner->getMorphedName("genitive", false)}
+        {else}
+            {_playlists_group}
+        {/if}
+    {/if}
+{/block}
+
+{block header}
+    <div n:if="$mode == 'list'">
+        <div n:if="$isMy">{_my_audios_small}</div>
+        <div n:if="!$isMy">
+            <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a>
+            »
+            {_audios}
+        </div>
+    </div>
+
+    <div n:if="$mode == 'new'">
+        {_audios}
+        »
+        {_audio_new}
+    </div>
+
+    <div n:if="$mode == 'popular'">
+        {_audios}
+        »
+        {_audio_popular}
+    </div>
+
+    <div n:if="$mode == 'playlists'">
+        {_audios}
+        »
+        {if $isMy}{_my_playlists}{else}{_playlists}{/if}
+    </div>
+{/block}
+
+{block content}
+    {* ref: https://archive.li/P32em *}
+    
+    {include "bigplayer.xml"}
+
+    <input n:if="$mode == 'list'" type="hidden" name="bigplayer_context" data-type="entity_audios" data-entity="{$ownerId}" data-page="{$page}">
+    <input n:if="$mode == 'new'" type="hidden" name="bigplayer_context" data-type="new_audios" data-entity="0" data-page="1">
+    <input n:if="$mode == 'popular'" type="hidden" name="bigplayer_context" data-type="popular_audios" data-entity="0" data-page="1">
+    <div class="bigPlayerDetector"></div>
+
+    <div style="width: 100%;display: flex;margin-bottom: -10px;" class="audiosDiv">
+        <div style="width: 74%;" class="audiosContainer" n:if="$mode != 'playlists'">
+            <div style="padding: 8px;">
+                <div n:if="$audiosCount <= 0">
+                    {include "../components/error.xml", description => $ownerId > 0 ? ($ownerId == $thisUser->getId() ? tr("no_audios_thisuser") : tr("no_audios_user")) : tr("no_audios_club")}
+                </div>
+                <div n:if="$audiosCount > 0" class="infContainer">
+                    <div class="infObj" n:foreach="$audios as $audio">
+                        {include "player.xml", audio => $audio, club => $club}
+                    </div>
+                </div>
+
+                <div n:if="$mode != 'new' && $mode != 'popular'">
+                    {include "../components/paginator.xml", conf => (object) [
+                        "page"     => $page,
+                        "count"    => $audiosCount,
+                        "amount"   => sizeof($audios),
+                        "perPage"  => $perPage ?? OPENVK_DEFAULT_PER_PAGE,
+                        "atBottom" => true,
+                    ]}
+                </div>
+            </div>
+        </div>
+        
+        <div style="width: 74%;" n:if="$mode == 'playlists'">
+            <div style="padding: 8px;">
+                <div n:if="$playlistsCount <= 0">
+                    {include "../components/error.xml", description => $ownerId > 0 ? ($ownerId == $thisUser->getId() ? tr("no_playlists_thisuser") : tr("no_playlists_user")) : tr("no_playlists_club")}
+                </div>
+
+                <div class="infContainer playlistContainer" n:if="$playlistsCount > 0">
+                    <div class="infObj" n:foreach="$playlists as $playlist">
+                        <a href="/playlist{$playlist->getPrettyId()}">
+                            <div class="playlistCover">
+                                <img src="{$playlist->getCoverURL()}" alt="{_playlist_cover}">
+                            </div>
+                        </a>
+
+
+                        <div class="playlistInfo">
+                            <a href="/playlist{$playlist->getPrettyId()}">
+                                <span style="font-size: 12px" class="playlistName">
+                                    {ovk_proc_strtr($playlist->getName(), 15)}
+                                </span>
+                            </a>
+
+                            <a href="{$playlist->getOwner()->getURL()}">{ovk_proc_strtr($playlist->getOwner()->getCanonicalName(), 20)}</a>
+                        </div>
+                    </div>
+                </div>
+
+                <div>
+                    {include "../components/paginator.xml", conf => (object) [
+                        "page"     => $page,
+                        "count"    => $playlistsCount,
+                        "amount"   => sizeof($playlists),
+                        "perPage"  => $perPage ?? OPENVK_DEFAULT_PER_PAGE,
+                        "atBottom" => true,
+                    ]}
+                </div>
+            </div>
+        </div>
+        {include "tabs.xml"}
+    </div>
+{/block}
diff --git a/Web/Presenters/templates/Audio/NewPlaylist.xml b/Web/Presenters/templates/Audio/NewPlaylist.xml
new file mode 100644
index 00000000..42efae7f
--- /dev/null
+++ b/Web/Presenters/templates/Audio/NewPlaylist.xml
@@ -0,0 +1,109 @@
+{extends "../@layout.xml"}
+
+{block title}
+    {_new_playlist}
+{/block}
+
+{block header}
+    {if !$_GET["gid"]}
+        <a href="{$thisUser->getURL()}">{$thisUser->getCanonicalName()}</a>
+        »
+        <a href="/audios{$thisUser->getId()}">{_audios}</a>
+    {else}
+        <a href="{$club->getURL()}">{$club->getCanonicalName()}</a>
+        »
+        <a href="/audios-{$club->getId()}">{_audios}</a>
+    {/if}
+    »
+    {_new_playlist}
+{/block}
+
+{block content}
+    <style>
+        textarea[name='description'] {
+            padding: 4px;
+        }
+
+        .playlistInfo {
+            width: 76%;
+            margin-left: 8px;
+        }
+    </style>
+
+    <div style="display:flex;">
+        <div class="playlistCover" onclick="document.querySelector(`#newPlaylistForm input[name='cover']`).click()">
+            <a>
+                <img src="/assets/packages/static/openvk/img/song.jpg" alt="{_playlist_cover}">
+            </a>
+        </div>
+
+        <div style="padding-left: 17px;width: 75%;" class="plinfo">
+            <div>
+                <input type="text" name="title" placeholder="{_title}" maxlength="125" />
+            </div>
+            <div class="moreInfo" style="margin-top: 11px;">
+                <textarea placeholder="{_description}" name="description" maxlength="2045" />
+            </div>
+        </div>
+    </div>
+
+    <div style="margin-top: 19px;">
+        <input id="playlist_query" type="text" style="height: 26px;" placeholder="{_header_search}">
+        <div class="playlistAudiosContainer editContainer">
+            <div id="newPlaylistAudios" n:foreach="$audios as $audio">
+                <div style="width: 78%;float: left;">
+                    {include "player.xml", audio => $audio, hideButtons => true}
+                </div>
+                <div class="attachAudio addToPlaylist" data-id="{$audio->getId()}">
+                    <span>{_add_to_playlist}</span>
+                </div>
+            </div>
+        </div>
+
+        <div class="showMoreAudiosPlaylist" data-page="2" {if !is_null($_GET["gid"])}data-club="{abs($_GET['gid'])}"{/if} n:if="$pagesCount > 1">
+            {_show_more_audios}
+        </div>
+    </div>
+
+    <form method="post" id="newPlaylistForm" enctype="multipart/form-data">
+        <input type="hidden" name="title" maxlength="125" />
+        <input type="hidden" name="hash" value="{$csrfToken}" />
+        <textarea style="display:none;" name="description" maxlength="2045" />
+        <input type="hidden" name="audios">
+        <input type="file" style="display:none;" name="cover" accept=".jpg,.png">
+
+        <div style="float: right;margin-top: 9px;">
+            <button class="button" type="submit">{_create}</button>
+        </div>
+    </form>
+
+    <script>
+        document.querySelector("input[name='audios']").value = ""
+
+        u("#newPlaylistForm").on("submit", (e) => {
+            document.querySelector("#newPlaylistForm input[name='title']").value = document.querySelector(".plinfo input[name='title']").value
+            document.querySelector("#newPlaylistForm textarea[name='description']").value = document.querySelector(".plinfo textarea[name='description']").value
+        })
+
+        u("#newPlaylistForm input[name='cover']").on("change", (e) => {
+            if(!e.currentTarget.files[0].type.startsWith("image/")) {
+                fastError(tr("not_a_photo"))
+                return
+            }
+
+            let image = URL.createObjectURL(e.currentTarget.files[0])
+
+            document.querySelector(".playlistCover img").src = image
+            document.querySelector(".playlistCover img").style.display = "block"
+        })
+
+        u(".playlistCover img").on("click", (e) => {
+            document.querySelector("#newPlaylistForm input[name='cover']").value = ""
+            e.currentTarget.href = ""
+        })
+
+        document.querySelector("#newPlaylistForm input[name='cover']").value = ""
+    </script>
+
+    {script "js/al_playlists.js"}
+{/block}
diff --git a/Web/Presenters/templates/Audio/Playlist.xml b/Web/Presenters/templates/Audio/Playlist.xml
new file mode 100644
index 00000000..f14b6ac1
--- /dev/null
+++ b/Web/Presenters/templates/Audio/Playlist.xml
@@ -0,0 +1,81 @@
+{extends "../@layout.xml"}
+
+{block title}{_playlist}{/block}
+
+{block headIncludes}
+    <meta property="og:type" content="music.album">
+    <meta property="og:title" content="{$playlist->getName()}">
+    <meta property="og:url" content="{$playlist->getURL()}">
+    <meta property="og:description" content="{$playlist->getDescription()}">
+    <meta property="og:image" content="{$playlist->getCoverURL()}">
+    
+    <script type="application/ld+json">
+        {
+            "@context": "http://schema.org/",
+            "type": "MusicAlbum",
+            "name": {$playlist->getName()},
+            "url": {$playlist->getURL()}
+        }
+    </script>
+{/block}
+
+{block header}
+    <a href="{$owner->getURL()}">{$owner->getCanonicalName()}</a>
+    »
+    <a href="/audios{$ownerId}">{_audios}</a>
+    »
+    {_playlist}
+{/block}
+
+{block content}
+    {include "bigplayer.xml"}
+
+    {php $count = $playlist->size()}
+    
+    <input type="hidden" name="bigplayer_context" data-type="playlist_context" data-entity="{$playlist->getId()}" data-page="{$page}">
+    <div class="playlistBlock">
+        <div class="playlistCover" style="float: left;">
+            <a href="{$playlist->getCoverURL()}" target="_blank">
+                <img src="{$playlist->getCoverURL('normal')}" alt="{_playlist_cover}">
+            </a>
+
+            <div class="profile_links" style="width: 139px;">
+                <a class="profile_link" style="width: 98%;" href="/playlist{$playlist->getPrettyId()}/edit" n:if="$playlist->canBeModifiedBy($thisUser)">{_edit_playlist}</a>
+                <a class="profile_link" style="width: 98%;" id="bookmarkPlaylist" data-id="{$playlist->getId()}" n:if="!$isBookmarked">{_bookmark}</a>
+                <a class="profile_link" style="width: 98%;" id="unbookmarkPlaylist" data-id="{$playlist->getId()}" n:if="$isBookmarked">{_unbookmark}</a>
+            </div>
+        </div>
+
+        <div style="float: left;padding-left: 13px;width:75%">
+            <div class="playlistInfo">
+                <h4 style="border-bottom:unset;">{$playlist->getName()}</h4>
+
+                <div class="moreInfo">
+                    {$playlist->getMetaDescription()|noescape}
+
+                    <div style="margin-top: 11px;">
+                        <span>{nl2br($playlist->getDescriptionHTML())|noescape}</span>
+                    </div>
+                    <hr style="color: #f7f7f7;">
+                </div>
+            </div>
+            <div class="audiosContainer infContainer" style="margin-top: 14px;">
+                {if $count < 1}
+                    {_empty_playlist}
+                {else}  
+                    <div class="infObj" n:foreach="$audios as $audio">
+                        {include "player.xml", audio => $audio}
+                    </div>
+
+                    {include "../components/paginator.xml", conf => (object) [
+                        "page"     => $page,
+                        "count"    => $count,
+                        "amount"   => sizeof($audios),
+                        "perPage"  => $perPage ?? OPENVK_DEFAULT_PER_PAGE,
+                        "atBottom" => true,
+                    ]}
+                {/if}
+            </div>
+        </div>
+    </div>
+{/block}
diff --git a/Web/Presenters/templates/Audio/Upload.xml b/Web/Presenters/templates/Audio/Upload.xml
new file mode 100644
index 00000000..ecc4aa30
--- /dev/null
+++ b/Web/Presenters/templates/Audio/Upload.xml
@@ -0,0 +1,212 @@
+{extends "../@layout.xml"}
+
+{block title}
+    {_upload_audio}
+{/block}
+
+{block header}
+    {if !is_null($group)}
+        <a href="{$group->getURL()}">{$group->getCanonicalName()}</a>
+        »
+        <a href="/audios-{$group->getId()}">{_audios}</a>
+    {else}
+        <a href="{$thisUser->getURL()}">{$thisUser->getCanonicalName()}</a>
+        »
+        <a href="/audios{$thisUser->getId()}">{_audios}</a>
+    {/if}
+
+    »
+    {_upload_audio}
+{/block}
+
+{block content}
+    <div class="container_gray" style="border: 0;margin-top: -10px;">
+        <div id="upload_container">
+            <div id="firstStep">
+                <h4>{_select_audio}</h4><br/>
+                <b><a href="javascript:void(0)">{_limits}</a></b>
+                <ul>
+                    <li>{tr("audio_requirements", 1, 30, 25)}</li>
+                    <li>{tr("audio_requirements_2")}</li>
+                </ul>
+                <div id="audio_upload">
+                    <form enctype="multipart/form-data" method="POST">
+                        <input type="hidden" name="name" />
+                        <input type="hidden" name="performer" />
+                        <input type="hidden" name="lyrics" />
+                        <input type="hidden" name="genre" />
+                        <input type="hidden" name="explicit" />
+                        
+                        <input type="hidden" name="hash" value="{$csrfToken}" />
+                        <input id="audio_input" type="file" name="blob" accept="audio/*" style="display:none" />
+                        <input value="{_upload_button}" class="button" type="button" onclick="document.querySelector('#audio_input').click()">
+                    </form>
+                </div><br/>
+            
+                <span>{_you_can_also_add_audio_using} <b><a href="/search?type=audios">{_search_audio_inst}</a></b>.<span>
+            </div>
+
+            <div id="lastStep" style="display:none;">
+                <table cellspacing="7" cellpadding="0" border="0" align="center">
+                    <tbody>
+                        <tr>
+                            <td width="120" valign="top"><span class="nobold">{_audio_name}:</span></td>
+                            <td><input type="text" name="name" autocomplete="off" maxlength="80" /></td>
+                        </tr>
+                        <tr>
+                            <td width="120" valign="top"><span class="nobold">{_performer}:</span></td>
+                            <td><input name="performer" type="text" autocomplete="off" maxlength="80" /></td>
+                        </tr>
+                        <tr>
+                            <td width="120" valign="top"><span class="nobold">{_genre}:</span></td>
+                            <td>
+                                <select name="genre">
+                                    <option n:foreach='\openvk\Web\Models\Entities\Audio::genres as $genre' n:attr="selected: $genre == 'Other'" value="{$genre}">
+                                        {$genre}
+                                    </option>
+                                </select>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td width="120" valign="top"><span class="nobold">{_lyrics}:</span></td>
+                            <td><textarea name="lyrics" style="resize: vertical;max-height: 300px;"></textarea></td>
+                        </tr>
+                        <tr>
+                            <td width="120" valign="top"></td>
+                            <td>
+                                <label><input type="checkbox" name="explicit">{_audios_explicit}</label>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td width="120" valign="top"></td>
+                            <td>
+                                <input class="button" type="button" id="uploadMuziko" value="{_upload_button}">
+                                <input class="button" type="button" id="backToUpload" value="{_select_another_file}">
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+    
+    <script type="module">
+        import * as id3 from "/assets/packages/static/openvk/js/node_modules/id3js/lib/id3.js";
+        
+        u("#audio_input").on("change", async function(e) {
+            let files = e.currentTarget.files
+            if(files.length <= 0)
+                return;
+            
+            document.querySelector("#firstStep").style.display = "none"
+            document.querySelector("#lastStep").style.display = "block"
+            
+            let tags = await id3.fromFile(files[0]);
+            if(tags != null) {
+                console.log("ID" + tags.kind + " detected, setting values...");
+                
+                if(tags.title != null)
+                    document.querySelector("#lastStep input[name=name]").value = tags.title;
+                else
+                    document.querySelector("#lastStep input[name=name]").value = files[0].name
+                
+                if(tags.artist != null)
+                    document.querySelector("#lastStep input[name=performer]").value = tags.artist;
+                else
+                    document.querySelector("#lastStep input[name=performer]").value = tr("track_unknown");
+                
+                if(tags.genre != null) {
+                    if(document.querySelector("#lastStep select[name=genre] > option[value='" + tags.genre + "']") != null) {
+                        document.querySelector("#lastStep select[name=genre]").value = tags.genre;
+                    } else {
+                        console.warn("Unknown genre: " + tags.genre);
+                        document.querySelector("#lastStep select[name=genre]").value = "Other"
+                    }
+                }
+            } else {
+                document.querySelector("#lastStep input[name=name]").value = files[0].name
+                document.querySelector("#lastStep select[name=genre]").value = "Other"
+                document.querySelector("#lastStep input[name=performer]").value = tr("track_unknown");
+            }
+        });
+
+        u("#backToUpload").on("click", (e) => {
+            document.querySelector("#firstStep").style.display = "block"
+            document.querySelector("#lastStep").style.display = "none"
+
+            document.querySelector("#lastStep input[name=name]").value = ""
+            document.querySelector("#lastStep input[name=performer]").value = ""
+            document.querySelector("#lastStep select[name=genre]").value = ""
+            document.querySelector("#lastStep textarea[name=lyrics]").value = ""
+            document.querySelector("#audio_input").value = ""
+        })
+
+        u("#uploadMuziko").on("click", (e) => {
+            var name_   = document.querySelector("#audio_upload input[name=name]");
+            var perf_   = document.querySelector("#audio_upload input[name=performer]");
+            var genre_  = document.querySelector("#audio_upload input[name=genre]");
+            var lyrics_ = document.querySelector("#audio_upload input[name=lyrics]");
+            var explicit_ = document.querySelector("#audio_upload input[name=explicit]");
+
+            name_.value = document.querySelector("#lastStep input[name=name]").value
+            perf_.value = document.querySelector("#lastStep input[name=performer]").value
+            genre_.value = document.querySelector("#lastStep select[name=genre]").value
+            lyrics_.value = document.querySelector("#lastStep textarea[name=lyrics]").value
+            explicit_.value = document.querySelector("#lastStep input[name=explicit]").checked ? "on" : "off"
+
+            $("#audio_upload > form").trigger("submit");
+        })
+
+        $(document).on("dragover drop", (e) => {
+            e.preventDefault()
+
+            return false;
+        })
+
+        $(".container_gray").on("drop", (e) => {
+            e.originalEvent.dataTransfer.dropEffect = 'move';
+            e.preventDefault()
+
+            let file = e.originalEvent.dataTransfer.files[0]
+
+            if(!file.type.startsWith('audio/')) {
+                MessageBox(tr("error"), tr("only_audios_accepted", escapeHtml(file.name)), [tr("ok")], [() => Function.noop])
+                return;
+            }
+
+            document.getElementById("audio_input").files = e.originalEvent.dataTransfer.files
+            u("#audio_input").trigger("change")
+        })
+        
+        $("#audio_upload").on("submit", "form", (e) => {
+            e.preventDefault()
+
+            let fd = new FormData(e.currentTarget)
+            fd.append("ajax", 1)
+
+            $.ajax({
+                type: "POST",
+                url: location.href,
+                contentType: false,
+                processData: false,
+                data: fd,
+                beforeSend: function() {
+                    document.querySelector("#lastStep").classList.add("lagged")
+                    document.querySelector("#upload_container").classList.add("uploading")
+                },
+                success: (response) => {
+                    document.querySelector("#lastStep").classList.remove("lagged")
+                    document.querySelector("#upload_container").classList.remove("uploading")
+                    if(response.success) {
+                        u("#backToUpload").trigger("click")
+                        NewNotification(tr("success"), tr("audio_successfully_uploaded"), null, () => {
+                            window.location.assign(response.redirect_link)
+                        })
+                    } else {
+                        fastError(response.flash.message)
+                    }
+                }
+            })
+        })
+    </script>
+{/block}
diff --git a/Web/Presenters/templates/Audio/bigplayer.xml b/Web/Presenters/templates/Audio/bigplayer.xml
new file mode 100644
index 00000000..7e8ffbe9
--- /dev/null
+++ b/Web/Presenters/templates/Audio/bigplayer.xml
@@ -0,0 +1,56 @@
+<div class="bigPlayer">
+    <audio class="audio" />
+    <div class="paddingLayer">
+        <div class="playButtons">
+            <div class="playButton musicIcon" title="{_play_tip} [Space]"></div>
+                
+            <div class="arrowsButtons">
+                <div>
+                    <div class="nextButton musicIcon"></div>
+                </div>
+
+                <div>
+                    <div class="backButton musicIcon"></div>
+                </div>
+            </div>
+        </div>
+
+        <div class="trackPanel">
+            <div class="trackInfo">
+                <div class="trackName">
+                    <b>{_track_unknown}</b> —
+                    <span>{_track_noname}</span>
+                </div>
+
+                <div class="timer" style="float:right">
+                    <span class="time">00:00</span>
+                    <span>/</span>
+                    <span class="elapsedTime" style="cursor:pointer">-00:00</span>
+                </div>
+            </div>
+
+            <div class="track" style="margin-top: -2px;">
+                <div class="bigPlayerTip">00:00</div>
+                <div class="selectableTrack">
+                    <div style="width: 95%;position: relative;">&nbsp;
+                        <div class="slider"></div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <div class="volumePanel">
+            <div class="selectableTrack">
+                <div style="position: relative;width:72%">&nbsp;
+                    <div class="slider"></div>
+                </div>
+            </div>
+        </div>
+
+        <div class="additionalButtons">
+            <div class="repeatButton musicIcon" title="{_repeat_tip} [R]" ></div>
+            <div class="shuffleButton musicIcon" title="{_shuffle_tip}"></div>
+            <div class="deviceButton musicIcon" title="{_mute_tip} [M]"></div>
+        </div>
+    </div>
+</div>
diff --git a/Web/Presenters/templates/Audio/player.xml b/Web/Presenters/templates/Audio/player.xml
new file mode 100644
index 00000000..fb5862b4
--- /dev/null
+++ b/Web/Presenters/templates/Audio/player.xml
@@ -0,0 +1,69 @@
+{php $id = $audio->getId() . rand(0, 1000)}
+{php $isWithdrawn = $audio->isWithdrawn()}
+{php $editable = isset($thisUser) && $audio->canBeModifiedBy($thisUser)}
+<div id="audioEmbed-{$id}" data-realid="{$audio->getId()}" {if $hideButtons}data-prettyid="{$audio->getPrettyId()}" data-name="{$audio->getName()}"{/if} data-genre="{$audio->getGenre()}" class="audioEmbed {if !$audio->isAvailable()}processed{/if} {if $isWithdrawn}withdrawn{/if}" data-length="{$audio->getLength()}" data-keys="{json_encode($audio->getKeys())}" data-url="{$audio->getURL()}">
+    <audio class="audio" />
+
+    <div id="miniplayer" class="audioEntry" style="min-height: 39px;">
+        <div style="display: flex;">
+            <div class="playerButton">
+                <div class="playIcon"></div>
+            </div>
+
+            <div class="status" style="margin-top: 12px;">
+                <div class="mediaInfo" style="margin-bottom: -8px; cursor: pointer;display:flex;width: 85%;">
+                    <div class="info noOverflow">
+                        <strong class="performer">
+                            <a href="/search?query=&type=audios&sort=id&only_performers=on&query={$audio->getPerformer()}">{ovk_proc_strtr($audio->getPerformer(), 30)}</a>
+                        </strong>
+                        —
+                        <span class="title {if !empty($audio->getLyrics())}withLyrics{/if}">{ovk_proc_strtr($audio->getTitle(), 30)}</span>
+                    </div>
+
+                    <div class="explicitMark" n:if="$audio->isExplicit()"></div>
+                </div>
+            </div>
+
+            <div class="volume" style="display: flex; flex-direction: column;width:14%;">
+                <span class="nobold {if !$hideButtons}hideOnHover{/if}" data-unformatted="{$audio->getLength()}" style="text-align: center;margin-top: 12px;">{$audio->getFormattedLength()}</span>
+                <div class="buttons" style="margin-top: 8px;">
+                    {php $hasAudio = isset($thisUser) && $audio->isInLibraryOf($thisUser)}
+
+                    {if !$hideButtons}
+                        <div class="remove-icon musicIcon" data-id="{$audio->getId()}" n:if="isset($thisUser) && $hasAudio" ></div>
+                        <div class="add-icon musicIcon hovermeicon" data-id="{$audio->getId()}" n:if="isset($thisUser) && !$hasAudio && !$isWithdrawn" ></div>
+                        <div class="remove-icon-group musicIcon" data-id="{$audio->getId()}" data-club="{$club->getId()}" n:if="isset($thisUser) && isset($club) && $club->canBeModifiedBy($thisUser)" ></div>
+                        <div class="add-icon-group musicIcon" data-id="{$audio->getId()}" n:if="isset($thisUser) && !$isWithdrawn" ></div>
+                        <div class="edit-icon musicIcon" data-lyrics="{$audio->getLyrics()}" data-title="{$audio->getTitle()}" data-performer="{$audio->getPerformer()}" data-explicit="{(int)$audio->isExplicit()}" data-searchable="{(int)!$audio->isUnlisted()}" n:if="isset($thisUser) && $editable && !$isWithdrawn" ></div>
+                        <div class="report-icon musicIcon" data-id="{$audio->getId()}" n:if="isset($thisUser) && !$editable && !$isWithdrawn" ></div>
+                    {/if}
+                </div>
+            </div>
+        </div>
+        <div class="subTracks">
+            <div style="width: 100%;">
+                <div class="track lengthTrack" style="margin-top: 3px;display:none">
+                    <div class="selectableTrack" style="width: 100%;" n:attr="style => $isWithdrawn ? 'display: none;' : ''">
+                        <div style="position: relative;width: calc(100% - 18px);">
+                            <div class="slider"></div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div style="width: 81px;margin-left: 16px;">
+                <div class="track volumeTrack" style="margin-top: 3px;display:none">
+                    <div class="selectableTrack" style="width: 100%;" n:attr="style => $isWithdrawn ? 'display: none;' : ''">
+                        <div style="position: relative;width: calc(100% - 18px);">
+                            <div class="slider"></div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="lyrics" n:if="!empty($audio->getLyrics())">
+        {nl2br($audio->getLyrics())|noescape}
+    </div>
+</div>
diff --git a/Web/Presenters/templates/Audio/tabs.xml b/Web/Presenters/templates/Audio/tabs.xml
new file mode 100644
index 00000000..ed74ca86
--- /dev/null
+++ b/Web/Presenters/templates/Audio/tabs.xml
@@ -0,0 +1,40 @@
+<div class="searchOptions newer">
+    <div class="searchList" style="margin-top:10px">
+        <a n:attr="id => $mode === 'list' && $isMy ? 'used' : 'ki'" href="/audios{$thisUser->getId()}" n:if="isset($thisUser)">{_my_music}</a>
+        <a href="/player/upload{if $isMyClub}?gid={abs($ownerId)}{/if}" n:if="isset($thisUser)">{_upload_audio}</a>
+        <a n:attr="id => $mode === 'new' ? 'used' : 'ki'" href="/audios/new">{_audio_new}</a>
+        <a n:attr="id => $mode === 'popular' ? 'used' : 'ki'" href="/audios/popular">{_audio_popular}</a>
+        <a href="/search?type=audios" n:if="isset($thisUser)">{_audio_search}</a>
+
+        <hr n:if="isset($thisUser)">
+
+        <a n:attr="id => $mode === 'playlists' && $ownerId == $thisUser->getId() ? 'used' : 'ki'" href="/playlists{$thisUser->getId()}" n:if="isset($thisUser)">{_my_playlists}</a>
+
+        <a n:if="isset($thisUser)" href="/audios/newPlaylist">{_new_playlist}</a>
+
+        {if !$isMy && $mode !== 'popular' && $mode !== 'new'}
+            <hr>
+            
+            <a n:if="!$isMy" n:attr="id => $mode === 'list' ? 'used' : 'ki'" href="/audios{$ownerId}">{if $ownerId > 0}{_music_user}{else}{_music_club}{/if}</a>
+            <a href="/player/upload?gid={abs($ownerId)}" n:if="isset($thisUser) && isset($club) && $club->canUploadAudio($thisUser)">{_upload_audio}</a>
+            <a n:attr="id => $mode === 'playlists' && $ownerId != $thisUser->getId() ? 'used' : 'ki'" href="/playlists{$ownerId}"  n:if="isset($thisUser) && isset($ownerId) && !$isMy">{if $ownerId > 0}{_playlists_user}{else}{_playlists_club}{/if}</a>
+            <a href="/audios/newPlaylist{if $isMyClub}?gid={abs($ownerId)}{/if}" n:if="isset($thisUser) && $isMyClub">{_new_playlist}</a>
+        {/if}
+
+        {if $friendsAudios}
+            <div class="friendsAudiosList">
+                <a href="/audios{$fr->getRealId()}" style="width: 94%;padding-left: 10px;" n:foreach="$friendsAudios as $fr">
+                    <div class="elem">
+                        <img src="{$fr->getAvatarURL()}" />
+
+                        <div class="additionalInfo">
+                            {php $audioStatus = $fr->getCurrentAudioStatus()}
+                            <span class="name">{$fr->getCanonicalName()}</span>
+                            <span class="desc">{$audioStatus ? $audioStatus->getName() : tr("audios_count", $fr->getAudiosCollectionSize())}</span>
+                        </div>
+                    </div>
+                </a>
+            </div>
+        {/if}
+    </div>
+</div>
diff --git a/Web/Presenters/templates/Group/Edit.xml b/Web/Presenters/templates/Group/Edit.xml
index 22f0b490..8f85e7e7 100644
--- a/Web/Presenters/templates/Group/Edit.xml
+++ b/Web/Presenters/templates/Group/Edit.xml
@@ -102,6 +102,15 @@
                         </td>
                     </tr>
 
+                    <tr>
+                        <td width="120" valign="top">
+                            <span class="nobold">{_audios}: </span>
+                        </td>
+                        <td>
+                            <label><input type="checkbox" name="upload_audios" value="1" n:attr="checked => $club->isEveryoneCanUploadAudios()" /> {_everyone_can_upload_audios}</label>
+                        </td>
+                    </tr>
+
                     <tr>
                         <td>
                             
diff --git a/Web/Presenters/templates/Group/View.xml b/Web/Presenters/templates/Group/View.xml
index 0242bbb8..de00d9b0 100644
--- a/Web/Presenters/templates/Group/View.xml
+++ b/Web/Presenters/templates/Group/View.xml
@@ -90,6 +90,25 @@
             </div>
         </div>
     </div>
+
+    <div>
+        <div class="content_title_expanded" onclick="hidePanel(this, {$audiosCount});">
+            {_audios}
+        </div>
+        <div>
+            <div class="content_subtitle">
+                {tr("audios_count", $audiosCount)}
+                <div style="float:right;">
+                    <a href="/audios-{$club->getId()}">{_all_title}</a>
+                </div>
+            </div>
+            <div class="content_list long">
+                <div class="audio" n:foreach="$audios as $audio" style="width: 100%;">
+                    {include "../Audio/player.xml", audio => $audio}
+                </div>
+            </div>
+        </div>
+    </div>
     
     {presenter "openvk!Wall->wallEmbedded", -$club->getId()}
 </div>
diff --git a/Web/Presenters/templates/Photos/UploadPhoto.xml b/Web/Presenters/templates/Photos/UploadPhoto.xml
index ab81d3aa..4f5f8e70 100644
--- a/Web/Presenters/templates/Photos/UploadPhoto.xml
+++ b/Web/Presenters/templates/Photos/UploadPhoto.xml
@@ -35,7 +35,7 @@
 
                 <div class="limits" style="margin-top:17px">
                     <b style="color:#45688E">{_admin_limits}</b>
-                    <ul class="blueList" style="margin-left: -25px;margin-top: 1px;">
+                    <ul style="margin-top: 6px;">
                         <li>{_supported_formats}</li>
                         <li>{_max_load_photos}</li>
                     </ul>
diff --git a/Web/Presenters/templates/Report/Tabs.xml b/Web/Presenters/templates/Report/Tabs.xml
index e878ccb3..6003f202 100644
--- a/Web/Presenters/templates/Report/Tabs.xml
+++ b/Web/Presenters/templates/Report/Tabs.xml
@@ -46,6 +46,9 @@
     <div n:attr="id => ($mode === 'user' ? 'activetabs' : 'ki')" class="tab" mode="user">
         <a n:attr="id => ($mode === 'user' ? 'act_tab_a' : 'ki')">Пользователи</a>
     </div>
+    <div n:attr="id => ($mode === 'audio' ? 'activetabs' : 'ki')" class="tab" mode="audio">
+        <a n:attr="id => ($mode === 'audio' ? 'act_tab_a' : 'ki')">{_audios}</a>
+    </div>
 </center>
 
 <script>
diff --git a/Web/Presenters/templates/Report/ViewContent.xml b/Web/Presenters/templates/Report/ViewContent.xml
index 249e6b38..1f5918d5 100644
--- a/Web/Presenters/templates/Report/ViewContent.xml
+++ b/Web/Presenters/templates/Report/ViewContent.xml
@@ -23,6 +23,8 @@
             {if $appsSoftDeleting}
                 {include "./content/app.xml", app => $object}
             {/if}
+        {elseif $type == "audio"}
+            {include "../Audio/player.xml", audio => $object}
         {else}
             {include "../components/error.xml", description => tr("version_incompatibility")}
         {/if}
diff --git a/Web/Presenters/templates/Search/Index.xml b/Web/Presenters/templates/Search/Index.xml
index cb1f54bd..acc1eab7 100644
--- a/Web/Presenters/templates/Search/Index.xml
+++ b/Web/Presenters/templates/Search/Index.xml
@@ -205,12 +205,14 @@
                 </div>
             {elseif $type == "videos"}
                 {foreach $data as $dat}
-                        <div class="content">
-                            {include "../components/video.xml", video => $dat}
-                        </div>
+                    <div class="content">
+                        {include "../components/video.xml", video => $dat}
+                    </div>
                 {/foreach}
             {elseif $type == "audios"}
-                хуй
+                {foreach $data as $dat}
+                    {include "../Audio/player.xml", audio => $dat}
+                {/foreach}
             {/if}
         {else}
             {ifset customErrorMessage}
@@ -231,12 +233,13 @@
 {block searchOptions}
     <div class="searchOptions">
         <ul class="searchList">
-            <li {if $type === "users"}   id="used"{/if}><a href="/search?type=users{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">   {_s_people}   {if $type === "users"}   ({$count}){/if}</a></li>
-            <li {if $type === "groups"}  id="used"{/if}><a href="/search?type=groups{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">  {_s_groups}   {if $type === "groups"}  ({$count}){/if}</a></li>
-            <li {if $type === "comments"}id="used"{/if}><a href="/search?type=comments{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">{_s_comments} {if $type === "comments"}({$count}){/if}</a></li>
-            <li {if $type === "posts"}   id="used"{/if}><a href="/search?type=posts{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">   {_s_posts}    {if $type === "posts"}   ({$count}){/if}</a></li>
-            <li {if $type === "videos"}  id="used"{/if}><a href="/search?type=videos{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">  {_s_videos}   {if $type === "videos"}  ({$count}){/if}</a></li>
-            <li {if $type === "apps"}    id="used"{/if}><a href="/search?type=apps{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">    {_s_apps}     {if $type === "apps"}    ({$count}){/if}</a></li>
+            <a {if $type === "users"}   id="used"{/if} href="/search?type=users{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">   {_s_people}   {if $type === "users"}   ({$count}){/if}</a>
+            <a {if $type === "groups"}  id="used"{/if} href="/search?type=groups{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">  {_s_groups}   {if $type === "groups"}  ({$count}){/if}</a>
+            <a {if $type === "comments"}id="used"{/if} href="/search?type=comments{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">{_s_comments} {if $type === "comments"}({$count}){/if}</a>
+            <a {if $type === "posts"}   id="used"{/if} href="/search?type=posts{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">   {_s_posts}    {if $type === "posts"}   ({$count}){/if}</a>
+            <a {if $type === "videos"}  id="used"{/if} href="/search?type=videos{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">  {_s_videos}   {if $type === "videos"}  ({$count}){/if}</a>
+            <a {if $type === "apps"}    id="used"{/if} href="/search?type=apps{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">    {_s_apps}     {if $type === "apps"}    ({$count}){/if}</a>
+            <a {if $type === "audios"}  id="used"{/if} href="/search?type=audios{if $_GET['query']}&query={urlencode($_GET['query'])}{/if}&sort=id">  {_s_audios}   {if $type === "audios"}  ({$count}){/if}</a>
         </ul>
 
         <div class="searchOption">
@@ -250,6 +253,11 @@
                             <option value="rating" {if $_GET["sort"] == "rating"}selected{/if}>{_s_order_by_rating}</option>
                         {/if}
                     {/if}
+
+                    {if $type == "audios"}
+                        <option value="length" n:attr="selected => $_GET['sort'] == 'length'">{_s_order_by_length}</option>
+                        <option value="listens" n:attr="selected => $_GET['sort'] == 'listens'">{_s_order_by_listens}</option>
+                    {/if}
                 </select>
                 <div id="invertor">
                 <input type="checkbox" name="invert" value="1" form="searcher" {if !is_null($_GET['invert']) && $_GET['invert'] == "1"}checked{/if}>{_s_order_invert}
@@ -352,9 +360,19 @@
                 <input type="text" value="{if !is_null($_GET['fav_quote'])}{$_GET['fav_quote']}{/if}" form="searcher" placeholder="{_favorite_quotes}"  name="fav_quote">
             </div>
         </div>
-        <!--<input name="with_photo" type="checkbox" {if !is_null($_GET['with_photo']) && $_GET['with_photo'] == "on"}checked{/if} form="searcher">{_s_with_photo}-->
     {/if}
-        <input class="button" type="button" id="dnt" value="{_reset}" onclick="resetSearch()">
+
+    {if $type == "audios"}
+        <div class="searchOption">
+        <div class="searchOptionName" id="n_main_audio" onclick="hideParams('main_audio')"><img src="/assets/packages/static/openvk/img/hide.png" class="searchHide">{_s_main}</div>
+            <div class="searchOptionBlock" id="s_main_audio">
+                <label><input type="checkbox" name="only_performers" n:attr="checked => !empty($_GET['only_performers'])" form="searcher">{_s_only_performers}</label><br>
+                <label><input type="checkbox" name="with_lyrics" n:attr="checked => !empty($_GET['with_lyrics'])" form="searcher">{_s_with_lyrics}</label>
+            </div>
+        </div>
+    {/if}
+    
+    <input class="button" type="button" id="dnt" value="{_reset}" onclick="resetSearch()">
     </div>
 
 {/block}
diff --git a/Web/Presenters/templates/User/Edit.xml b/Web/Presenters/templates/User/Edit.xml
index b8e8398c..0a28b879 100644
--- a/Web/Presenters/templates/User/Edit.xml
+++ b/Web/Presenters/templates/User/Edit.xml
@@ -160,6 +160,13 @@
                             </select>
                         </td>
                     </tr>
+                    <tr>
+                        <td width="120" valign="top">
+                        </td>
+                        <td>
+                            <label><input type="checkbox" name="broadcast_music" n:attr="checked => $user->isBroadcastEnabled()">{_broadcast_audio}</label>
+                        </td>
+                    </tr>
                     <tr>
                         <td>
 
diff --git a/Web/Presenters/templates/User/Settings.xml b/Web/Presenters/templates/User/Settings.xml
index c8f0f61b..6fb9322f 100644
--- a/Web/Presenters/templates/User/Settings.xml
+++ b/Web/Presenters/templates/User/Settings.xml
@@ -323,6 +323,19 @@
                         </select>
                     </td>
                 </tr>
+                <tr>
+                    <td width="120" valign="top">
+                        <span class="nobold">{_privacy_setting_view_audio}</span>
+                    </td>
+                    <td>
+                        <select name="audios.read" style="width: 164px;">
+                            <option value="3" {if $user->getPrivacySetting('audios.read') == 3}selected{/if}>{_privacy_value_anybody_dative}</option>
+                            <option value="2" {if $user->getPrivacySetting('audios.read') == 2}selected{/if}>{_privacy_value_users}</option>
+                            <option value="1" {if $user->getPrivacySetting('audios.read') == 1}selected{/if}>{_privacy_value_friends_dative}</option>
+                            <option value="0" {if $user->getPrivacySetting('audios.read') == 0}selected{/if}>{_privacy_value_only_me_dative}</option>
+                        </select>
+                    </td>
+                </tr>
                 <tr>
                     <td width="120" valign="top">
                         <span class="nobold">{_privacy_setting_see_notes}</span>
@@ -609,6 +622,17 @@
                             <span class="nobold">{_my_videos}</span>
                         </td>
                     </tr>
+                    <tr>
+                        <td width="120" valign="top" align="right">
+                            <input
+                                  n:attr="checked => $user->getLeftMenuItemStatus('audios')"
+                                  type="checkbox"
+                                  name="menu_muziko" />
+                        </td>
+                        <td>
+                            <span class="nobold">{_my_audios}</span>
+                        </td>
+                    </tr>
                     <tr>
                         <td width="120" valign="top" align="right">
                             <input
diff --git a/Web/Presenters/templates/User/View.xml b/Web/Presenters/templates/User/View.xml
index 9014750d..f5cb6176 100644
--- a/Web/Presenters/templates/User/View.xml
+++ b/Web/Presenters/templates/User/View.xml
@@ -417,6 +417,10 @@
                 <form name="status_popup_form" onsubmit="changeStatus(); return false;">
                     <div style="margin-bottom: 10px;">
                         <input type="text" name="status" size="50" value="{$user->getStatus()}" />
+                        <label style="width: 316px;display: block;">
+                            <input type="checkbox" name="broadcast" n:attr="checked => $user->isBroadcastEnabled()" />
+                            {_broadcast_audio}
+                        </label>
                     </div>
                     <input type="hidden" name="hash" value="{$csrfToken}" />
                     <button type="submit" name="submit" class="button" style="height: 22px;">{_send}</button>
@@ -425,11 +429,20 @@
 			<div class="accountInfo clearFix">
                 <div class="profileName">
                     <h2>{$user->getFullName()}</h2>
-                    {if !is_null($user->getStatus())}
-                        <div n:class="page_status, $thatIsThisUser ? page_status_edit_button" n:attr="id => $thatIsThisUser ? page_status_text : NULL">{$user->getStatus()}</div>
-                    {elseif $thatIsThisUser}
-                        <div class="page_status">
-                            <div n:class="edit_link, $thatIsThisUser ? page_status_edit_button" id="page_status_text">[ {_change_status} ]</div>
+
+                    {if !$audioStatus}
+                        {if !is_null($user->getStatus())}
+                            <div n:class="page_status, $thatIsThisUser ? page_status_edit_button" n:attr="id => $thatIsThisUser ? page_status_text : NULL">{$user->getStatus()}</div>
+                        {elseif $thatIsThisUser}
+                            <div class="page_status">
+                                <div n:class="edit_link, $thatIsThisUser ? page_status_edit_button" id="page_status_text">[ {_change_status} ]</div>
+                            </div>
+                        {/if}
+                    {else}
+                        <div class="page_status" style="display: flex;">
+                            <div n:class="audioStatus, $thatIsThisUser ? page_status_edit_button" id="page_status_text">
+                                {$audioStatus->getName()}
+                            </div>
                         </div>
                     {/if}
                 </div>
@@ -530,7 +543,7 @@
                             {var $musics = explode(", ", $user->getFavoriteMusic())}
 
                             {foreach $musics as $music}
-                                <a href="/search?type=users&query=&fav_mus={urlencode($music)}">{$music}</a>{if $music != end($musics)},{/if}
+                                <a href="/search?type=audios&query={urlencode($music)}">{$music}</a>{if $music != end($musics)},{/if}
                             {/foreach}
                         </td>
                     </tr>
@@ -594,6 +607,25 @@
             </div>
         </div>
 
+        <div n:if="$audiosCount > 0 && $user->getPrivacyPermission('audios.read', $thisUser ?? NULL)">
+            <div class="content_title_expanded" onclick="hidePanel(this, {$audiosCount});">
+                {_audios}
+            </div>
+            <div>
+                <div class="content_subtitle">
+                    {tr("audios_count", $audiosCount)}
+                    <div style="float:right;">
+                        <a href="/audios{$user->getId()}">{_all_title}</a>
+                    </div>
+                </div>
+                <div class="content_list long">
+                    <div class="audio" n:foreach="$audios as $audio" style="width: 100%;">
+                        {include "../Audio/player.xml", audio => $audio}
+                    </div>
+                </div>
+            </div>
+        </div>
+
         <div n:if="OPENVK_ROOT_CONF['openvk']['preferences']['commerce'] && ($giftCount = $user->getGiftCount()) > 0">
             <div class="content_title_expanded" onclick="hidePanel(this, {$giftCount});">
                 {_gifts}
@@ -739,12 +771,14 @@
 
             async function changeStatus() {
                 const status = document.status_popup_form.status.value;
+                const broadcast = document.status_popup_form.broadcast.checked;
 
                 document.status_popup_form.submit.innerHTML = "<div class=\"button-loading\"></div>";
                 document.status_popup_form.submit.disabled = true;
 
                 const formData = new FormData();
                 formData.append("status", status);
+                formData.append("broadcast", Number(broadcast));
                 formData.append("hash", document.status_popup_form.hash.value);
                 const response = await ky.post("/edit?act=status", {body: formData});
 
diff --git a/Web/Presenters/templates/_includeCSS.xml b/Web/Presenters/templates/_includeCSS.xml
index 60409c27..8e1aef6a 100644
--- a/Web/Presenters/templates/_includeCSS.xml
+++ b/Web/Presenters/templates/_includeCSS.xml
@@ -10,6 +10,7 @@
             {css "css/dialog.css"}
             {css "css/notifications.css"}
             {css "css/avataredit.css"}
+            {css "css/audios.css"}
 
             {if $isXmas}
                 {css "css/xmas.css"}
@@ -27,6 +28,7 @@
         {css "css/dialog.css"}
         {css "css/notifications.css"}
         {css "css/avataredit.css"}
+        {css "css/audios.css"}
 
         {if $isXmas}
             {css "css/xmas.css"}
@@ -50,7 +52,7 @@
     {css "css/dialog.css"}
     {css "css/nsfw-posts.css"}
     {css "css/notifications.css"}
-    {css "css/avataredit.css"}
+    {css "css/audios.css"}
 
     {if $isXmas}
         {css "css/xmas.css"}
diff --git a/Web/Presenters/templates/components/attachment.xml b/Web/Presenters/templates/components/attachment.xml
index a46be837..85a33f21 100644
--- a/Web/Presenters/templates/components/attachment.xml
+++ b/Web/Presenters/templates/components/attachment.xml
@@ -50,6 +50,10 @@
     {else}
         {include "post.xml", post => $attachment, compact => true}
     {/if}
+{elseif $attachment instanceof \openvk\Web\Models\Entities\Audio}
+    <div style="width:100%;">
+        {include "../Audio/player.xml", audio => $attachment}
+    </div>
 {else}
     <span style="color:red;">{_version_incompatibility}</span>
 {/if}
diff --git a/Web/Presenters/templates/components/textArea.xml b/Web/Presenters/templates/components/textArea.xml
index 8727addf..530174b1 100644
--- a/Web/Presenters/templates/components/textArea.xml
+++ b/Web/Presenters/templates/components/textArea.xml
@@ -22,6 +22,7 @@
                 
             </div>
             <div class="post-has-videos"></div>
+            <div class="post-has-audios"></div>
 
             <div n:if="$postOpts ?? true" class="post-opts">
                 {var $anonEnabled = OPENVK_ROOT_CONF['openvk']['preferences']['wall']['anonymousPosting']['enable']}
@@ -62,6 +63,7 @@
             </div>
             <input type="hidden" name="photos" value="" />
             <input type="hidden" name="videos" value="" />
+            <input type="hidden" name="audios" value="" />
             <input type="hidden" name="poll" value="none" />
             <input type="hidden" id="note" name="note" value="none" />
             <input type="hidden" name="type" value="1" />
@@ -85,6 +87,10 @@
                         <img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/mimetypes/application-vnd.rn-realmedia.png" />
                         {_video}
                     </a>
+                    <a id="_audioAttachment">
+                        <img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/mimetypes/audio-ac3.png" />
+                        {_audio}
+                    </a>
                     <a n:if="$notes ?? false" href="javascript:attachNote({$textAreaId})">
                         <img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/mimetypes/application-x-srt.png" />
                         {_note}
@@ -110,6 +116,7 @@
 
     u("#post-buttons{$textAreaId} input[name='videos']")["nodes"].at(0).value = ""
     u("#post-buttons{$textAreaId} input[name='photos']")["nodes"].at(0).value = ""
+    u("#post-buttons{$textAreaId} input[name='audios']")["nodes"].at(0).value = ""
 </script>
 
 {if $graffiti}
diff --git a/Web/di.yml b/Web/di.yml
index 57d46f14..81e06117 100644
--- a/Web/di.yml
+++ b/Web/di.yml
@@ -7,6 +7,7 @@ services:
     - openvk\Web\Presenters\CommentPresenter
     - openvk\Web\Presenters\PhotosPresenter
     - openvk\Web\Presenters\VideosPresenter
+    - openvk\Web\Presenters\AudioPresenter
     - openvk\Web\Presenters\BlobPresenter
     - openvk\Web\Presenters\GroupPresenter
     - openvk\Web\Presenters\SearchPresenter
@@ -33,6 +34,7 @@ services:
     - openvk\Web\Models\Repositories\Albums
     - openvk\Web\Models\Repositories\Clubs
     - openvk\Web\Models\Repositories\Videos
+    - openvk\Web\Models\Repositories\Audios
     - openvk\Web\Models\Repositories\Notes
     - openvk\Web\Models\Repositories\Tickets
     - openvk\Web\Models\Repositories\Messages
diff --git a/Web/routes.yml b/Web/routes.yml
index dea8ddd5..d12ccbc0 100644
--- a/Web/routes.yml
+++ b/Web/routes.yml
@@ -187,6 +187,34 @@ routes:
       handler: "Videos->edit"
     - url: "/video{num}_{num}/remove"
       handler: "Videos->remove"
+    - url: "/player/upload"
+      handler: "Audio->upload"
+    - url: "/audios{num}"
+      handler: "Audio->list"
+    - url: "/audios/popular"
+      handler: "Audio->popular"
+    - url: "/audios/new"
+      handler: "Audio->new"
+    - url: "/audio{num}_{num}/embed.xhtml"
+      handler: "Audio->embed"
+    - url: "/audio{num}/listen"
+      handler: "Audio->listen"
+    - url: "/audios/search"
+      handler: "Audio->search"
+    - url: "/audios/newPlaylist"
+      handler: "Audio->newPlaylist"
+    - url: "/audios/context"
+      handler: "Audio->apiGetContext"
+    - url: "/playlist{num}_{num}"
+      handler: "Audio->playlist"
+    - url: "/playlist{num}_{num}/edit"
+      handler: "Audio->editPlaylist"
+    - url: "/playlist{num}/action"
+      handler: "Audio->playlistAction"
+    - url: "/playlists{num}"
+      handler: "Audio->playlists"
+    - url: "/audio{num}/action"
+      handler: "Audio->action"
     - url: "/{?!club}{num}"
       handler: "Group->view"
       placeholders:
@@ -221,24 +249,6 @@ routes:
       handler: "Topics->edit"
     - url: "/topic{num}_{num}/delete"
       handler: "Topics->delete"
-    - url: "/audios{num}"
-      handler: "Audios->app"
-    - url: "/audios{num}.json"
-      handler: "Audios->apiListSongs"
-    - url: "/audios/popular.json"
-      handler: "Audios->apiListPopSongs"
-    - url: "/audios/playlist{num}.json"
-      handler: "Audios->apiListPlaylists"
-    - url: "/audios/search.json"
-      handler: "Audios->apiSearch"
-    - url: "/audios/add.json"
-      handler: "Audios->apiAdd"
-    - url: "/audios/playlist.json"
-      handler: "Audios->apiAddPlaylist"
-    - url: "/audios/upload.json"
-      handler: "Audios->apiUpload"
-    - url: "/audios/beacon"
-      handler: "Audios->apiBeacon"
     - url: "/im"
       handler: "Messenger->index"
     - url: "/im/sel{num}"
@@ -341,6 +351,12 @@ routes:
       handler: "Admin->bannedLink"
     - url: "/admin/bannedLink/id{num}/unban"
       handler: "Admin->unbanLink"
+    - url: "/admin/music"
+      handler: "Admin->music"
+    - url: "/admin/music/{num}/edit"
+      handler: "Admin->editMusic"
+    - url: "/admin/playlist/{num}/edit"
+      handler: "Admin->editPlaylist"
     - url: "/admin/user{num}/bans"
       handler: "Admin->bansHistory"
     - url: "/upload/photo/{text}"
diff --git a/Web/static/css/audios.css b/Web/static/css/audios.css
new file mode 100644
index 00000000..5decbb89
--- /dev/null
+++ b/Web/static/css/audios.css
@@ -0,0 +1,661 @@
+.noOverflow {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.overflowedName {
+    position: absolute;
+    z-index: 99;
+}
+
+.musicIcon {
+    background-image: url('/assets/packages/static/openvk/img/audios_controls.png');
+    background-repeat: no-repeat;
+    cursor: pointer;
+}
+
+.musicIcon:hover {
+    filter: brightness(99%);
+}
+
+.musicIcon.pressed {
+    filter: brightness(150%);
+}
+
+.bigPlayer {
+    background-color: rgb(240, 241, 242);
+    margin-left: -10px;
+    margin-top: -10px;
+    width: 102.8%;
+    height: 46px;
+    border-bottom: 1px solid #d8d8d8;
+    box-shadow: 1px 0px 8px 0px rgba(34, 60, 80, 0.2);
+    position: relative;
+}
+
+.bigPlayer.floating {
+    position: fixed;
+    z-index: 199;
+    width: 627px;
+    margin-top: -76px;
+}
+
+.bigPlayer .paddingLayer {
+    padding: 0px 0px 0px 14px;
+}
+
+.bigPlayer .paddingLayer .playButtons {
+    padding: 12px 0px;
+}
+
+.bigPlayer .paddingLayer .playButtons .playButton {
+    width: 22px;
+    height: 22px;
+    float: left;
+    background-position-x: -72px;
+}
+
+.bigPlayer .paddingLayer .playButtons .playButton.pause {
+    background-position-x: -168px;
+}
+
+.bigPlayer .paddingLayer .playButtons .nextButton {
+    width: 16px;
+    height: 16px;
+    background-position-y: -47px;
+}
+
+.bigPlayer .paddingLayer .playButtons .backButton {
+    width: 16px;
+    height: 16px;
+    background-position-y: -47px;
+    background-position-x: -16px;
+    margin-left: 6px;
+}
+
+.bigPlayer .paddingLayer .additionalButtons {
+    float: left;
+    margin-top: -6px;
+    width: 11%;
+}
+
+.bigPlayer .paddingLayer .additionalButtons .repeatButton {
+    width: 14px;
+    height: 16px;
+    background-position-y: -49px;
+    background-position-x: -31px;
+    margin-left: 7px;
+    float: left;
+}
+
+.broadcastButton {
+    width: 16px;
+    height: 12px;
+    background-position-y: -50px;
+    background-position-x: -64px;
+    margin-left: 6px;
+    float: left;
+}
+
+.broadcastButton.atProfile {
+    width: 13px;
+    height: 12px;
+    background-position-y: -50px;
+    background-position-x: -64px;
+    margin-left: 0px !important;
+    margin-right: 5px;
+    float: left;
+}
+
+.bigPlayer .paddingLayer .additionalButtons .shuffleButton {
+    width: 14px;
+    height: 16px;
+    background-position: -50px -50px;
+    margin-left: 7px;
+    float: left;
+}
+
+.bigPlayer .paddingLayer .additionalButtons .deviceButton {
+    width: 12px;
+    height: 16px;
+    background-position: -202px -50px;
+    margin-left: 7px;
+    float: left;
+}
+
+.bigPlayer .paddingLayer .playButtons .arrowsButtons {
+    float: left;
+    display: flex;
+    padding-left: 4px;
+    padding-top: 1.2px;
+}
+
+.bigPlayer .paddingLayer .trackPanel {
+    float: left;
+    margin-top: -13px;
+    margin-left: 13px;
+    width: 63%;
+    position: relative;
+}
+
+.bigPlayer .paddingLayer .bigPlayerTip {
+    display: none;
+    z-index: 999;
+    background: #cecece;
+    padding: 3px;
+    top: -3px;
+    position: absolute;
+    transition: all .1s ease-out;
+    user-select: none;
+}
+
+.bigPlayer .paddingLayer .volumePanel {
+    width: 73px;
+    float: left;
+}
+
+.bigPlayer .paddingLayer .slider, .audioEmbed .track .slider {
+    width: 18px;
+    height: 7px;
+    background: #606060;
+    position: absolute;
+    bottom: 0;
+    top: 0px;
+    pointer-events: none;
+}
+
+.bigPlayer .paddingLayer .trackInfo .timer {
+    float: right;
+    margin-right: 8px;
+    font-size: 10px;
+}
+
+.bigPlayer .paddingLayer .trackInfo .trackName {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    width: 81%;
+    display: inline-block;
+}
+
+.bigPlayer .paddingLayer .trackInfo .timer span {
+    font-size: 10px;
+}
+
+.bigPlayer .paddingLayer .trackInfo b:hover {
+    text-decoration: underline;
+    cursor: pointer;
+}
+
+.audioEmbed .track > .selectableTrack, .bigPlayer .selectableTrack {
+    margin-top: 3px;
+    width: calc(100% - 8px);
+    border-top: #606060 1px solid;
+    height: 6px;
+    position: relative;
+    user-select: none;
+}
+
+#audioEmbed {
+    cursor: pointer;
+    user-select: none;
+    background: #eee;
+    height: 40px;
+    width: 486px;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    margin-right: -50%;
+    transform: translate(-50%, -50%);
+    overflow: hidden;
+    border: 1px solid #8B8B8B;
+}
+
+.audioEntry.nowPlaying {
+    background: #606060;
+    border: 1px solid #4f4f4f;
+    box-sizing: border-box;
+}
+
+.audioEntry.nowPlaying .playIcon {
+    background-position-y: -16px !important;
+}
+
+.audioEntry.nowPlaying:hover {
+    background: #4e4e4e !important;
+}
+
+.audioEntry.nowPlaying .performer a {
+    color: #f4f4f4 !important;
+}
+
+.audioEntry .performer a {
+    color: #4C4C4C;
+}
+
+.audioEntry.nowPlaying .title {
+    color: #fff;
+}
+
+.audioEntry.nowPlaying .status {
+    color: white;
+}
+
+.audioEntry.nowPlaying .volume .nobold {
+    color: white !important;
+}
+
+.audioEntry.nowPlaying .buttons .musicIcon, .audioEntry.nowPlaying .explicitMark {
+    filter: brightness(187%) opacity(72%);
+}
+
+.audioEntry {
+    height: 100%;
+    position: relative;
+    width: 100%;
+}
+
+.audioEntry .playerButton {
+    position: relative;
+    padding: 10px 9px 9px 9px;
+    width: 16px;
+    height: 16px;
+}
+
+.audioEntry .subTracks {
+    display: flex;
+    padding-bottom: 5px;
+    padding-left: 8px;
+    padding-right: 12px;
+}
+
+.audioEntry .playerButton .playIcon {
+    background-image: url('/assets/packages/static/openvk/img/play_buttons.gif');
+    cursor: pointer;
+    width: 16px;
+    height: 16px;
+    background-repeat: no-repeat;
+}
+
+.audioEntry .playerButton .playIcon.paused {
+    background-position-y: -16px;
+}
+
+.audioEntry .status {
+    overflow: hidden;
+    display: grid;
+    grid-template-columns: 1fr;
+    width: 85%;
+    height: 23px;
+}
+
+.audioEntry .status strong {
+    color: #4C4C4C;
+}
+
+.audioEmbed .track {
+    display: none;
+    padding: 4px 0;
+}
+
+.audioEmbed .track, .audioEmbed.playing .track {
+    display: unset;
+}
+
+.audioEntry:hover {
+    background: #EEF0F2;
+    border-radius: 2px;
+}
+
+.audioEntry:hover .buttons {
+    display: block;
+}
+
+.audioEntry:hover .volume .hideOnHover {
+    display: none;
+}
+
+.audioEntry .buttons {
+    display: none;
+    width: 62px;
+    height: 20px;
+    position: absolute;
+    right: 3%;
+    top: 2px;
+    /* чтоб избежать заедания во время ховера кнопки добавления */
+    clip-path: inset(0 0 0 0);
+}
+
+.audioEntry .buttons .edit-icon {
+    width: 11px;
+    height: 11px;
+    float: right;
+    margin-right: 4px;
+    margin-top: 3px;
+    background-position: -137px -51px;
+}
+
+.audioEntry .buttons .add-icon {
+    width: 11px;
+    height: 11px;
+    float: right;
+    background-position: -80px -52px;
+    margin-top: 3px;
+    margin-left: 2px;
+}
+
+.audioEntry .buttons .add-icon-group {
+    width: 14px;
+    height: 11px;
+    float: right;
+    background-position: -94px -52px;
+    margin-top: 3px;
+    transition: margin-right 0.1s ease-out, opacity 0.1s ease-out;
+}
+
+.audioEntry .buttons .report-icon {
+    width: 12px;
+    height: 11px;
+    float: right;
+    background-position: -67px -51px;
+    margin-top: 3px;
+    margin-right: 3px;
+}
+
+.add-icon-noaction {
+    background-image: url('/assets/packages/static/openvk/img/audios_controls.png');
+    width: 11px;
+    height: 11px;
+    float: right;
+    background-position: -94px -52px;
+    margin-top: 2px;
+    margin-right: 2px;
+}
+
+.audioEntry .buttons .remove-icon {
+    margin-top: 3px;
+    width: 11px;
+    height: 11px;
+    margin-left: 2px;
+    float: right;
+    background-position: -108px -52px;
+}
+
+.audioEntry .buttons .remove-icon-group {
+    margin-top: 3px;
+    width: 13px;
+    height: 11px;
+    float: right;
+    background-position: -122px -52px;
+    margin-left: 3px;
+    margin-right: 3px;
+}
+
+.audioEmbed .lyrics {
+    display: none;
+    padding: 6px 33px 10px 33px;
+}
+
+.audioEmbed .lyrics.showed {
+    display: block !important;
+}
+
+.audioEntry .withLyrics {
+    user-select: none;
+    color: #507597;
+}
+
+.audioEntry .withLyrics:hover {
+    text-decoration: underline;
+}
+
+.audioEmbed.withdrawn .status > *, .audioEmbed.processed .status > *, .audioEmbed.withdrawn .playerButton > *, .audioEmbed.processed .playerButton > * {
+    pointer-events: none;
+}
+
+.audioEmbed.withdrawn {
+    filter: opacity(0.8);
+}
+
+.playlistCover img {
+    max-width: 135px;
+    max-height: 135px;
+}
+
+.playlistBlock {
+    margin-top: 14px;
+}
+
+.playlistContainer {
+    display: grid;
+    grid-template-columns: repeat(3, 146px);
+    gap: 18px 10px;
+}
+
+.playlistContainer .playlistCover {
+    width: 111px;
+    height: 111px;
+    display: flex;
+    background: #c4c4c4;
+}
+
+.playlistContainer  .playlistCover img {
+    max-width: 111px;
+    max-height: 111px;
+    margin: auto;
+}
+
+.ovk-diag-body .searchBox {
+    background: #e6e6e6;
+    padding-top: 10px;
+    height: 35px;
+    padding-left: 10px;
+    padding-right: 10px;
+    display: flex;
+}
+
+.ovk-diag-body .searchBox input {
+    height: 24px;
+    margin-right: -1px;
+    width: 77%;
+}
+
+.ovk-diag-body .searchBox select {
+    width: 29%;
+    padding-left: 8px;
+    height: 24px;
+}
+
+.ovk-diag-body .audiosInsert {
+    height: 82%;
+    padding: 9px 5px 9px 9px;
+    overflow-y: auto;
+}
+
+.attachAudio {
+    float: left;
+    width: 28%;
+    height: 26px;
+    padding-top: 11px;
+    text-align: center;
+}
+
+.attachAudio span {
+    user-select: none;
+}
+
+.attachAudio:hover {
+    background: rgb(236, 236, 236);
+    cursor: pointer;
+}
+
+.playlistCover img {
+    cursor: pointer;
+}
+
+.explicitMark {
+    margin-top: 2px;
+    margin-left: 3px;
+    width: 11px;
+    height: 11px;
+    background: url('/assets/packages/static/openvk/img/explicit.svg');
+    background-repeat: no-repeat;
+}
+
+.audioStatus span {
+    color: #2B587A;
+}
+
+.audioStatus span:hover {
+    text-decoration: underline;
+    cursor: pointer;
+}
+
+.audioStatus {
+    padding-top: 2px;
+    padding-bottom: 3px;
+}
+
+/* <center> 🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣*/
+.audiosDiv center span {
+    color: #707070;
+    margin: 120px 0px !important;
+    display: block;
+}
+
+.audiosDiv center {
+    margin-left: -10px;
+}
+
+.playlistInfo {
+    display: flex;
+    flex-direction: column;
+}
+
+.playlistInfo .playlistName {
+    font-weight: 600;
+}
+
+.searchList.floating {
+    position: fixed;
+    z-index: 199;
+    width: 156px;
+    margin-top: -65px !important;
+}
+
+.audiosSearchBox input[type='search'] {
+    height: 25px;
+    width: 77%;
+    padding-left: 21px;
+    padding-top: 4px;
+    background: rgb(255, 255, 255) url("/assets/packages/static/openvk/img/search_icon.png") 5px 6px no-repeat;
+}
+
+.audiosSearchBox {
+    padding-bottom: 10px;
+    padding-top: 7px;
+    display: flex;
+}
+
+.audiosSearchBox select {
+    width: 30%;
+    padding-left: 7px;
+    margin-left: -2px;
+}
+
+.audioStatus {
+    color: #2B587A;
+    margin-top: -3px;
+}
+
+.audioStatus::before {
+    background-image: url('/assets/packages/static/openvk/img/audios_controls.png');
+    background-repeat: no-repeat;
+    width: 11px;
+    height: 11px;
+    background-position: -66px -51px;
+    margin-top: 1px;
+    display: inline-block;
+    vertical-align: bottom;
+    content: "";
+    padding-right: 2px;
+}
+
+.friendsAudiosList {
+    margin-left: -7px;
+    margin-top: 8px;
+}
+
+.friendsAudiosList .elem {
+    display: flex;
+    padding: 1px 1px;
+    width: 148px;
+}
+
+.friendsAudiosList .elem img {
+    width: 30px;
+    border-radius: 2px;
+    object-fit: cover;
+    height: 31px;
+    min-width: 30px;
+}
+
+.friendsAudiosList .elem .additionalInfo {
+    margin-left: 7px;
+    padding-top: 1px;
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+}
+
+.friendsAudiosList .elem .additionalInfo .name {
+    color: #2B587A;
+}
+
+.friendsAudiosList #used .elem .additionalInfo .name {
+    color: #F4F4F4;
+}
+
+.friendsAudiosList .elem .additionalInfo .desc {
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+    color: #878A8F;
+    font-size: 11px;
+}
+
+.friendsAudiosList #used .elem .additionalInfo .desc {
+    color: #F4F4F4;
+}
+
+.friendsAudiosList .elem:hover {
+    background: #E8EBF0;
+    cursor: pointer;
+}
+
+.friendsAudiosList #used .elem:hover {
+    background: #787878;
+    cursor: pointer;
+}
+
+.editContainer {
+    display:table;
+    clear:both;
+    width:100%;
+    margin-top: 10px;
+}
+
+.editContainer .playerContainer {
+    width: 78%;
+    float: left;
+    max-width: 78%;
+    min-width: 68%;
+}
+
+.addToPlaylist {
+    width: 22%;
+}
diff --git a/Web/static/css/main.css b/Web/static/css/main.css
index 7b63d6f6..7a087705 100644
--- a/Web/static/css/main.css
+++ b/Web/static/css/main.css
@@ -1243,64 +1243,6 @@ table.User {
     border-bottom: 1px solid #c3cad2;
 }
 
-.music-app {
-    display: grid;
-}
-
-.music-app--player {
-    display: grid;
-    grid-template-columns: 32px 32px 32px 1fr;
-    padding: 8px;
-    border-bottom: 1px solid #c1c1c1;
-    border-bottom-style: solid;
-    border-bottom-style: dashed;
-}
-
-.music-app--player .play,
-.music-app--player .perv,
-.music-app--player .next {
-    -webkit-appearance: none;
-    -moz-appearance: none;
-    background-color: #507597;
-    color: #fff;
-    height: 20px;
-    margin: 5px;
-    border: none;
-    border-radius: 5px;
-    cursor: pointer;
-    padding: 0;
-    font-size: 10px;
-}
-
-.music-app--player .info {
-    margin-left: 10px;
-    width: 550px;
-}
-
-.music-app--player .info .song-name * {
-    color: black;
-}
-
-.music-app--player .info .song-name time {
-    float: right;
-}
-
-.music-app--player .info .track {
-    margin-top: 8px;
-    height: 5px;
-    width: 70%;
-    background-color: #fff;
-    border-top: 1px solid #507597;
-    float: left;
-}
-
-.music-app--player .info .track .inner-track {
-    background-color: #507597;
-    height: inherit;
-    width: 15px;
-    opacity: .7;
-}
-
 .settings_delete {
     margin: -12px;
     padding: 12px;
@@ -1477,7 +1419,7 @@ body.scrolled .toTop:hover {
     display: none;
 }
 
-.post-has-videos {
+.post-has-videos, .post-has-audios {
     margin-top: 11px;
     margin-left: 3px;
     color: #3c3c3c;
@@ -1494,7 +1436,7 @@ body.scrolled .toTop:hover {
     margin-left: 2px;
 }
 
-.post-has-video {
+.post-has-video, .post-has-audio {
     padding-bottom: 4px;
     cursor: pointer;
 }
@@ -1503,6 +1445,10 @@ body.scrolled .toTop:hover {
     text-decoration: underline;
 }
 
+.post-has-audio:hover span {
+    text-decoration: underline;
+}
+
 .post-has-video::before {
     content: " ";
     width: 14px;
@@ -1516,6 +1462,19 @@ body.scrolled .toTop:hover {
     margin-bottom: -1px;
 }
 
+.post-has-audio::before {
+    content: " ";
+    width: 14px;
+    height: 15px;
+    display: inline-block;
+    vertical-align: bottom;
+    background-image: url("/assets/packages/static/openvk/img/audio.png");
+    background-repeat: no-repeat;
+    margin: 3px;
+    margin-left: 2px;
+    margin-bottom: -1px;
+}
+
 .post-opts {
     margin-top: 10px;
 }
@@ -2123,6 +2082,45 @@ table td[width="120"] {
     margin: 0 auto;
 }
 
+#upload_container {
+    background: white;
+    padding: 30px 80px 20px;
+    margin: 10px 25px 30px;
+    border: 1px solid #d6d6d6;
+}
+
+#upload_container h4 {
+    border-bottom: solid 1px #daE1E8;
+    text-align: left;
+    padding: 0 0 4px 0;
+    margin: 0;
+}
+
+#audio_upload {
+    width: 350px;
+    margin: 20px auto;
+    margin-bottom: 10px;
+    padding: 15px 0;
+    border: 2px solid #ccc;
+    background-color: #EFEFEF;
+    text-align: center;
+}
+
+ul {
+    list-style: url(/assets/packages/static/openvk/img/bullet.gif) outside;
+    margin: 10px 0;
+    padding-left: 30px;
+    color: black;
+}
+
+li {
+    padding: 1px 0;
+}
+
+#upload_container ul {
+    padding-left: 15px;
+}
+
 #votesBalance {
     margin-top: 10px;
     padding: 7px;
@@ -2473,8 +2471,7 @@ a.poll-retract-vote {
     display: none;
 }
 
-.searchOptions
-{
+.searchOptions {
     overflow: hidden;
     width:25.5%;
     border-top:1px solid #E5E7E6;
@@ -2485,8 +2482,7 @@ a.poll-retract-vote {
     margin-right: -7px;
 }
 
-.searchBtn
-{
+.searchBtn {
     border: solid 1px #575757;
     background-color: #696969;
     color:white;
@@ -2498,52 +2494,47 @@ a.poll-retract-vote {
     margin-top: 1px;
 }
 
-.searchBtn:active
-{
+.searchBtn:active {
     border: solid 1px #666666;
     background-color: #696969;
     color:white;
     box-shadow: 0px -2px 0px 0px rgba(255, 255, 255, 0.18) inset;
 }
 
-.searchList
-{
+.searchList {
     list-style: none;
     user-select: none;
     padding-left:0px;
 }
 
-.searchList #used
-{
+.searchList #used {
     margin-left:0px;
-    color: white;
+    color: white !important;
     padding:2px;
     padding-top:5px;
     padding-bottom:5px;
-    border: solid 0.125rem #696969;
-    background:linear-gradient(#888888,#858585);
+    border: solid 0.125rem #4F4F4F;
+    background: #606060;
     margin-bottom:2px;
     padding-left:9px;
     width:87%;
 }
 
-.searchList #used a
-{
+.searchList #used a {
     color: white;
 }
 
-.sr:focus
-{
+.sr:focus {
     outline:none;
 }
 
-.searchHide
-{
+.searchHide {
     padding-right: 5px;
 }
 
-.searchList li
+.searchList li, .searchList a
 {
+    display: block;
     margin-left:0px;
     color: #2B587A !important;
     cursor:pointer;
@@ -2554,26 +2545,27 @@ a.poll-retract-vote {
     padding-left:9px;
 }
 
-.searchList li a
-{
+.searchList li a {
     min-width:100%;
 }
 
-.searchList li:hover
-{
-    margin-left:0px;
-    color: #2B587A !important;
-    background:#ebebeb;
-    padding:2px;
-    padding-top:5px;
-    padding-bottom:5px;
-    margin-bottom:2px;
-    padding-left:9px;
-    width:91%;
+.searchList a {
+    min-width: 88%;
 }
 
-.whatFind
-{
+.searchList a:hover {
+    margin-left: 0px;
+    color: #2B587A !important;
+    background: #ebebeb;
+    padding: 2px;
+    padding-top: 5px;
+    padding-bottom: 5px;
+    margin-bottom: 2px;
+    padding-left: 9px;
+    width: 89.9%;
+}
+
+.whatFind {
     color:rgb(128, 128, 128);
     background:none;
     border:none;
@@ -2585,8 +2577,7 @@ a.poll-retract-vote {
     margin-top: 0.5px;
 }
 
-.searchOptionName
-{
+.searchOptionName {
     cursor:pointer;
     background-color: #EAEAEA;
     padding-left:5px;
@@ -2598,8 +2589,7 @@ a.poll-retract-vote {
     border-bottom: 2px solid #E4E4E4;
 }
 
-.searchOption
-{
+.searchOption {
     user-select: none;
 }
 
@@ -2876,7 +2866,7 @@ body.article .floating_sidebar, body.article .page_content {
 
 .lagged {
     filter: opacity(0.5);
-    cursor: progress;
+    cursor: not-allowed;
     user-select: none;
 }
 
@@ -2890,7 +2880,7 @@ body.article .floating_sidebar, body.article .page_content {
     pointer-events: none;
 }
 
-.lagged * {
+.lagged *, .lagged {
     pointer-events: none;
 }
 
@@ -2975,3 +2965,40 @@ body.article .floating_sidebar, body.article .page_content {
     background: #E9F0F1 !important;
 }
 
+.searchOptions.newer {
+    padding-left: 6px;
+    border-top: unset !important;
+    height: unset !important;
+    border-left: 1px solid #d8d8d8;
+    width: 26% !important;
+}
+
+hr {
+    background-color: #d8d8d8;
+    border: none;
+    height: 1px;
+}
+
+.searchList hr {
+    width: 153px;
+    margin-left: 0px;
+    margin-top: 6px;
+}
+
+.showMore, .showMoreAudiosPlaylist {
+    width: 100%;
+    text-align: center;
+    background: #d5d5d5;
+    height: 22px;
+    padding-top: 9px;
+    cursor: pointer;
+}
+
+#upload_container.uploading {
+    background: white url('/assets/packages/static/openvk/img/progressbar.gif') !important;
+    background-position-x: 0% !important;
+    background-position-y: 0% !important;
+    background-repeat: repeat !important;
+    background-repeat: no-repeat !important;
+    background-position: 50% !important;
+}
diff --git a/Web/static/img/audio.png b/Web/static/img/audio.png
new file mode 100644
index 0000000000000000000000000000000000000000..62d9dff203a8f34d908941223522ab2ed648faa9
GIT binary patch
literal 560
zcmV-00?+-4P)<h;3K|Lk000e1NJLTq000gE000gM1^@s6A4o0H00001b5ch_0Itp)
z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGf6951U69E94oEQKA0mVr~K~y+Tol?DO
z0s#z8)vBQ4sVFE^hr`KN5F9#`zJ#tmKo?QF6h{TQ!QH`EXzAeQAR;&w#Kj*R{6X<V
z!T;uqC&jfNgzqlNm*kS8|Kh=5!SQ&c>2ykgK;Q|yTrTNwIM8G=q4W7nolXa7qf914
zCQ%p|kH@rHt-wR(;d;FmyId|qv)Mf46qvwbu%KG4QX~?g!C)Ya31BQbI2;brb^Xyu
zz3-UM=V>;Z(P%UR>zmC+x7lnu7b<tV-Ll<oCm3+o5m+o1<oEk&x7+2=B^V4+DwWa@
ztJP|6lEe^UqtTF;dc962({%{`E-jr-%kvEu%U>VK!uIC#ImP2~2*k44ELAEMNn(E1
zH0^o2-4YCt5+08Sltf2t)$8@*U6P8$Vv<LH=LJ!_-4;J^>zLz?CGJ>KRkg(5C&6em
zn&YjOLZOfv4u@6vZ!r<FD9d7>q9{J#bUI0pkN&~S^7Z?DYPDMAbUMWV8I{Mg<L}@*
z$vLo9V87qL@`#_!W|Q1*H*vui2{5E%B9V|o_j)~Yxm?(641T`j7gLOj#<&Q4--SW}
yp{tOmQ+SgKFGI0ftqQw-;71|jS?nevBKifrKlO%}3D{Tw0000<MNUMnLSTZVsQEqs

literal 0
HcmV?d00001

diff --git a/Web/static/img/audios_controls.png b/Web/static/img/audios_controls.png
new file mode 100644
index 0000000000000000000000000000000000000000..bf21832a08c0c8c47ee03636c97e1df49282514f
GIT binary patch
literal 3706
zcmV-=4u$cFP)<h;3K|Lk000e1NJLTq00992002J-1^@s6;Ol*q00001b5ch_0Itp)
z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!2kdb!2!6DYwZ944i8C0K~#8N?VWv$
z7DpY&_c^Wyr9Iw6W3<vnp;&8CgRzY*Mop|}YGa6Ug@Q;LwbxRPe>^C}Rx~6W{vqL7
zXj*$Hw%C%0@vR!8#uRHBBSu32ArNbAwW)-=Qo_q|$K&@m&&)DA&&=-3&hGQ@+|4KX
z-u`xW=DYjM?fjm3cAuBZqD6~3qA2<fv<#X7`7j9m5c>MF&pta$1!ei~-Me>8_;+=6
zh5o-p!~FwML`h)l<jIqJuD<%}sI9Gy7w6;R#f#DD)2E{|XU_EDBwR;j<louZ*)wIz
zltTPZojMi0`R1E__$S8xQ>RYtnLd4bA^wAdgV7srypiy4Z*T9p;)*MxN~OZ;<)vD!
zMi(wzh(<<6Vt*uo(Eprf^nW0V=)blcCt)-?I?6i8N8~|X;4Acpv;3;7t}4u*yg<UA
zPVl1q$qOX>Cr+Fgb#!z@bRznSyg<S~^nXp{(S-gV_fQg;K^d|rxR*R_^PR%!XsEyM
zGn{~)$+%a-KlFbc3ICG&f85jNJH^r=dj|h{f`2-V^bgc#0xb_c`xU8x8@fL9QJ}G8
zCV}nSw=<PR+5X$MZDT5nojZ3j6-`f1Puw>Y3ryv%l<mK2)vCDfDHfQ@UFq%Zjr*Qr
zfvG%T#`@&|ofNj@nQpoG=b_9*K;%sXroNgxcP@|YVi}wM_wl#k`s=UfkzMS{4L97t
zBfB_YhRp<YQ!*hL8mPKDs$}Sd)YTD^vGtcvau<I}1iCt^RDWA+6M^cfI|h}ka#=E_
z{!z&)m!<ZPu8vApxh&PcXv*+xWt^`G?AWn`N99nqe}8{JkIEtMj35wb7P;;YWDQi`
zOkTEsRwD5A&q@To{#gY7bjt8-Wybv70Ta5qLIF2y`Y@|pR-m!uN&?;8-8?FXvi*Io
zO53n<<w_m}0+&C|F9&P}sLymRkH7BOL`dcS`|sD*ljU>#HVMGT--br}>-tb7t6Wwi
zk3Zlz6KM4r040Dj2{V8|E5c?1x@;^YxcA<BwcB-4Gp(+Ukif-XMOQ~iaNm9RX}9Z?
zhr9{E)c*$$9^^G57H5^qvgv;xe-&MwLx&FWtr3f}%4OMb;J^VM*~I}fm9DO?Dt(N#
zNa*9NPd)V%I};Z#UR<3qqu9@DoIH6l+Oua*>_2nnOjceLCr+G5_+NSDmEIqd70}0J
z&z?OS`!8FzEcCxZBl|zlfV%@_y#A6sTiw5GTD^bD_7CK^Fla-gJ6SiS^id-T{a>#@
zW1%E4SY*eO7kTnk7Pq=T4-2Ew$KUGyJS>bdJX@Kz2@@vlrf+pA;vY#K<OR?8EpzSd
z?YqyNJ6D)LdBHQ`Pp|Qc@+U9Ve`I8Yy_PIO<R$xu{=WoyG`jx-I0rXu*zo+3BS)qU
z4-e0QCecZhSLom&WZ8k+Z@+#0O*h?ifp#!DI{N(K!-uEg<e39aqLV1EaC}3s99Xz;
z;re;=<|X`b^2~v}{Li01ul1+?!DE{PO`@;B$}60NLv3ws34fG@{^x2&{|7KLTC!wG
zv}@Nc`bO6Yw9ZQNLr({zkorxorTO1=*Im&w&pgwz$Il7<U!z(5Gp?zuU%#G*`PjH|
zBaiaY*Vo6Re9*Vk`mlNPW*+6^;fEjQQ9k$=gXU)K+O<5&$GUZXU%E^EM~NUWNFY24
zG>eb~#(5I(ff9&s{{@;4B>|dh75?K6NDgeug+X45Kg9fz1WNJ8$<6#R$V)wbB!N==
zksO#m25M>j6OzE#fZi;$1zHXHo*=|@h`heV)@lU*ON>STK#N03V0kSpl*Erm@MVz%
z|8yGZA82tX2^8@Rp(1+*|9Zxff1t%F{j&psK;ZI*E)3GWQzPQ)zw1?Sv}5;l`}+D)
z{l(R7*SiF0$Nbl=TbJrDE?l|Z3PU^QkH@X`zvrHNqFJ+MvH0beUyh!9@<|q(%2l%Z
z=kB}jj;^`p8Wz9u$}7>+Pe09KQ@Ki3|IkObue<I#7Qgh;OVMMGJ;q{FxeE19i(VKM
z{lWStC;On=Y9Zr~(1Kc+x-FGWn>Mjy+puAS&#{qD-7&zE9fNM0^-RF`R$vxo-|8y*
zWEBIQu!&s}1D&vmT@eGFu!%jO6I+{zEcqQIJSY~J%5CvECV3DM@_O1fD?~Ez!0Gs$
zUJisr5bz-HiTz2wBy3_Ad<R=;6p#q4alR%H5<$R&%W=?+`$UlI!eB@Q0S`hVaG(*1
zAotyYkO%@Eghb##*@+-`5(oqWV}smD!1seQS@?diC=1tHhJixyx&>Aem*X)pd~X?M
zkt+#=1Q75bBmf7>P5^{+5(o((;6X?L4m2VG5Kc)TB!GYic~9tH^Ce*uyD;t(0AZa3
zLIOxaZwVII#IDd=f(16QEA*CNflcfIoy<Nb^k*R9L9xJ8Zi~+`(OZFK<n^>|Ruqr`
zoQ^N^vjchAX!+Rz519Yp;9x2Kn=?K;Pyoz-^5n^-_^-A6Y(-wEe<-nrTdl26?z;nl
zKwxY@N#JBHEEL%@_}4R*`~xixB?0=*xT5(UUlPH;p0VT~XmKbB^g`Pp`VvWB$b-DT
z!PaU7|4WQT|3HfqzH;32!j|t{lkk<}fo4;opRa0V9(dpZ9_6FIzn@2e!1$yklEA7}
ztE!JY@(8=-6$k{1rDbjrQX=4CAP^`J@0kDwCy(60vpcoF1K|_U+gV`-zB2;fSq0j^
ziuUj1tMu+J9@W^iX%mmqw~Zz6I4MEz$K(5Ot;7k|7Ru8$h93j_zk2p_?4tw!|4CTT
z?=CEb-cM!V0Q4j137i|}Sji<)>qBb&hLf5EXu!}?9W-(|VpaxN8BjaWR4$Uh350K{
zm1H9yi?nYS630WwU<>y5Di-gMk3k*i;4jd8sP4p0OAx@Soq$eyl-KpCsa%k^oe65k
zL&7x?B-%o8Tln(G3fZTEW8k4P2_L?U!T+DW+4UFDJm@#jpCRIA=u^<&p*wJX(0M{w
z)rUm=reJ(IfCnshNQ|jm!nEB~E)u{38eBT@?RCjWg1lu>r+x$yd>0bi1ah2-NC369
zRA?Z}OIxhliGimKKx4TeZ#xs3`Yw;zr>%_Y<MbFLQho6MZ^z<i(7PZyR~JGLaeW$P
zAA{)YnSYMD^fT&-`p`n?A+AJyi0U^DZ4&TE00L7zV|#RD>IW^boh1OvW1zwM6NuV$
zL2IB?;QIo3n}jMh37~Xr2e9qBSZ>*}r9x8cnb6g9m`UjNVcS-wkv=q?{}A*woQp58
z5($3(eifoROQ5(uR97FU^_xar65uC%9j5XWO!ZxTY!U!%m$e7l2~mCe0sJB8gAnuK
z$T9IzzHAy|Al`W6jX#BcNo82lkATLQrt&<DQR|u7iGB9>_tUi!_L5-t?%j!fsx&wO
znEvNl9~=h{>u~bnOPS9@uR(j+nw}p+uS0i2uJxNnA_)|j00b@_UnF0G=v{4;3<#PD
z(CaUO?FnlGy_ToPBP1i2Q$3kPI~s@s0|V8^AAh_G?u0%&d-m+==;-MGFes-`13}h+
zGi_JP<$ZQ`c1BPY`#OqE$JKobnv?+4cFpjisrw-$%8Rwtdib_O5Y=V%A!B_Y*KZn^
z#VtaMXL?u8L-g9~i_jmSA3$0Bi7#ETeJ!ijt7}IWX*)VPqM@N7CW=Q!MkXv>y7UyU
z5@S2iRn+ZbpOs35`e+w48+uK*&nC|R#Co%Q=r#V?TA0cii0%lu)^8e#Bp?Q(imBWJ
zQ@gm_HUVJ!tvs}$2l{3$j6MPV6Z!yKt5LTv9LKG6Eb?O&>%8ngwbDB1s<sVky?C9T
zksp2Z(dxvB6WQQjvt~_&BoYChmFdcI0^NWlOyvTxUESx36)V0>!X`=t?^XASb~Lm4
zD>TXqfI|YPYYVd4>bC11TcdpF|8*PA!{bohlM2cPpkoN>A|TxbVD+KnknTxn0Fvvc
zvTxtMs*QN{7*n}S-Aq6p1Tv=b6ioH&%F%i8K~k&}Hi-*|y07b@?XtE)1np}FMANOm
zL9|UE$C=>q(|ZGSiGXfv(SC$v<Z|`ancObIw4WA?-rioO>C>lwAJ5QqoQY>;+kvj4
zmdpE;5Ov~xqTP!a@ZU!GIfz~4a!3F#LW#D(wry6uM*7eXc}|7i4Sk#n6c$16fjXh*
zAlLfQ{{8!pn6zNQg3M!7>&SyYEfHv64iu0GO!aKroA@t8S6gkMd$B|}L~qLQCM6N%
z2G7WBxq9{L%JA@TG;P{6x)`{VgyI#@aZJs>z$yXV0M`vjwVtkBcXxNZ&#haxR!M+n
z1Mxo5j{5OtEMLcd=&}FSApx+q5MBIYZ6Vilpr~`n*T?18Wv_)?>qqnF&!5ZI6hwJi
zB1lUB0#hO|UK8+01g7oAcCshMDWW&(=)P2v+N+YMSad5!lndlog{H1ru>bhof<+*n
z^&foj!D@SZdqjg0(BLQi19}^#<};~~PzRt`H<b(2b-ES+-x+v0a7rWs5@sHUv02_H
zU1TNRibNo01h&L&Au%J!L*m$hx^9b)kL@oX(?I(@<XWGhCIWR`mk4YUfIzn`^}?VS
ze0(*=P)_T0b%Ciofo?w~lDaP(%Y2XH5wG>tYXGTWYQF#I(WB9^W5=i+z02%Z2M->6
zAC|N5tW58!jL91KYPmq&4qy6)plesITv<7N`ZOhg=TN5Zb1}3Xx&`fSVI}s&ZMl39
zk4gL39LVK^^MR)K^!8txjsA<u6zHwM|Av;MF5QW;t`Es~uHu1jni<H0-X#GDl69i!
Y|EKD#P+H^wDF6Tf07*qoM6N<$g3W72`2YX_

literal 0
HcmV?d00001

diff --git a/Web/static/img/bullet.gif b/Web/static/img/bullet.gif
new file mode 100644
index 0000000000000000000000000000000000000000..bd5c540f311367fe2786f8c90a1a9b6bdd081677
GIT binary patch
literal 53
zcmZ?wbhEHbWMklA_`t|e+_U!o|Nn|VSy&ku7#VaJfB+=Jz{Jzjzw$H>KTl7h*9tBM
FYXF1T4W9r2

literal 0
HcmV?d00001

diff --git a/Web/static/img/explicit.svg b/Web/static/img/explicit.svg
new file mode 100644
index 00000000..df6f6c15
--- /dev/null
+++ b/Web/static/img/explicit.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="11" viewBox="0 0 11 11" width="11">
+    <path d="m1 2.506v5.988a1.5 1.5 0 0 0 1.491 1.506h6.019c.827 0 1.49-.674 1.49-1.506v-5.988a1.5 1.5 0 0 0 -1.491-1.506h-6.019c-.827 0-1.49.674-1.49 1.506zm4 2.494v-1h2v-1h-3v5h3v-1h-2v-1h2v-1zm-5-2.494a2.496 2.496 0 0 1 2.491-2.506h6.019a2.5 2.5 0 0 1 2.49 2.506v5.988a2.496 2.496 0 0 1 -2.491 2.506h-6.019a2.5 2.5 0 0 1 -2.49-2.506z"
+		fill="#828a99" fill-opacity=".7"/>
+</svg>
\ No newline at end of file
diff --git a/Web/static/img/favicons/favicon24_paused.png b/Web/static/img/favicons/favicon24_paused.png
new file mode 100644
index 0000000000000000000000000000000000000000..c8fdef269222dd0fff08c6177bd5f2d3a3a26a05
GIT binary patch
literal 932
zcmV;V16%xwP)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv00004XF*Lt006O`
zVN5~y0000WV@Og>004&&004{<008|>004nL003F*009yY002DZ000@zy2&Ck0003Y
zX+uL$Nkc;*P;zf(X>4Tx04UF6U|=$Eba8TJ5@2A+%_}Jia(7aQh>TKTKhMC%z{~&!
ziOIzUjsXEaAa-7UUMd3y_;!tf5kz0s1)0S_8sJJUC@KNce}LF1CAB!2fq~Hg$j&Y=
zDFBKy&H=JTlCr_<4Ip+=NRTs-eFVtnONX$pfY?b8_7f1h2<QnQ`wK{YI+)GG0X7H1
zmI1L#QY%3AGC1cKR2F5XXOt)yT3T8vIHu$$r78qh7MG;v7Att>CFd6v<QFBDq^1DX
zLfi)if)ItE5Yqr^w9sUrG62N`g!MTP9KMWVXAoitkX%|+24ylb858Cs%$Os=z~H}>
zfq~@@Ld>L>fnk9T0|Wn5gqWHk1A~A*1H-;K0Kmpi{cn=x1ONa432;bRa{vGi!~g&e
z!~vBn4jTXf0qsddK~zXftyViu12Gh|&1PLiBv$~gKxhyR*FZF>QjwmNe7Yzchz8LB
zN8k=1NEPA_GxjDJgCZGrHsiHD+4ub3^I9vX)2W!x=Z95QT?!!%Kn-q&2W9O3f(7_s
zj5+UiyZ3T3nH(;c%Uev=L2d!`uY~xhI-O&sYklRMt3lBg(Fp5&t+l>Z&bxv49$wqK
z9PRnwY+pI&wYL~p2no&77IXbPBY^`fTr2M&L~q~t7p$K3ykpT8wkw7cLc*2fW;Sbj
zLY$4qO)p}TCW9%a$qU8C!W$#WnW99q%!oxFc9liPagrfrOi`lIrscwsDirxE?>SSH
zXp91z=gR>*EK1H4B^r%0D|8eiuqISIXNnRnq=+?uBh;XYz;nBPXNnSSl`6c=+(k`D
z<|k8>Xffq6nz)OaLCzE<nh*A(lkgs}Agi!arYO;3%EM97>>}2IUNfdB(bg$~UB!@C
z1A5DuqC|@+#~lTi!{N}sy}kc>7X1~Hj-(X*-u5a*EHuMvhfERAp^zekv|uPaV%uUo
zp<xrY71~FznQVt>!X3S*t)0EyXKUPvkZ!9<;r{^#z=tvRRE|a?@%r*|)ale0@Hr4t
z>i9*1KZPGgoZ!k(xG)o?^_L6#97zaJ7t9JiTPD3;|LzkHSXjYrQUNId0000<MNUMn
GLSTYUih}C^

literal 0
HcmV?d00001

diff --git a/Web/static/img/favicons/favicon24_playing.png b/Web/static/img/favicons/favicon24_playing.png
new file mode 100644
index 0000000000000000000000000000000000000000..ce4478b8608a59911981069bb5e68e71bc423f32
GIT binary patch
literal 1081
zcmV-91jhS`P)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv00004XF*Lt006O`
zVN5~y0000WV@Og>004&&004{<008|>004nL003F*009yY002DZ000@zy2&Ck0003Y
zX+uL$Nkc;*P;zf(X>4Tx04UF6U|=$Eba8TJ5@2A+%_}Jia(7aQh>TKTKhMC%z{~&!
ziOIzUjsXEaAa-7UUMd3y_;!tf5kz0s1)0S_8sJJUC@KNce}LF1CAB!2fq~Hg$j&Y=
zDFBKy&H=JTlCr_<4Ip+=NRTs-eFVtnONX$pfY?b8_7f1h2<QnQ`wK{YI+)GG0X7H1
zmI1L#QY%3AGC1cKR2F5XXOt)yT3T8vIHu$$r78qh7MG;v7Att>CFd6v<QFBDq^1DX
zLfi)if)ItE5Yqr^w9sUrG62N`g!MTP9KMWVXAoitkX%|+24ylb858Cs%$Os=z~H}>
zfq~@@Ld>L>fnk9T0|Wn5gqWHk1A~A*1H-;K0Kmpi{cn=x1ONa432;bRa{vGi!~g&e
z!~vBn4jTXf0)k0IK~zXftyaBm8bJ`AJ-E9H1(0}wB#k4PE>2^miJSt&8$?ko3Pg*9
zWD<l^<VT|5Igluj=+i`La)~Uc^8hr+kwfD%x4Zdf?`-k2B!zF_yW5}H`F3V@M<)h@
z0WFuy<7_tjk%*oFKJahRfQXwG34!lX6uryka^FO^+dW>bR$nmanduaS`5mI0tn0pT
ze9!-+l=1*d3wV%J+Vg$?v!k>+(prNxy`N%x+K@eUl+wPI=txYY1@;64*tqX#B~54D
z#rs0@8=ID}jSzv+0D~5sp1z@cA#a`A3xyCd5$rnrkYHm^Y$%t@)Hyq&mnSEbafDT8
z8<I&R+OX?LG!zfAOBS$ro=4Scl{%fbba3#*s<#Oy!N%bQlFTmc$GVhCC2BUC)MzxQ
zSS(ug8SDjz>5hOKoD>^u!eQ(6dQ_=Y5Vp+jIvD4uFj4G*j7-LD*e9;*Qms~_^Y`a;
zSU%ib$06bzfCxEAJkWexckw?FLO^e8J=-&pF{dbTDY9dqpnlP0GQkP!(8bS-jWuz|
zV{hV9lyq#c$!s>miR#d3G@|S9dS{I`_F@uq4>%#WZf#>-7K;UOdGGi8G@s9{`iwZ_
z$we~*oCjvLfQ7>`9*?QjYEcjbR=o`{fobzaGrQ;NXUB9prFOeb!{Ly^FtqAyBMv#<
zNVuri>-y^Ik14ZhiC`j}u0sC7ZHPG0>{dG95OEzcA%aUGyJD(eN{Fj7Y{IsKX8<IF
z=>QM&M(aoB;p0cYWTak^P?;oz_ZJXAZlXxO7F->FUtT_UUGD=pk8t<>_=^Ne=8ql^
z@XnApGZIhTeYtR(djb=f<)aqhv!$EQ7ry=l-ND0C8%{=U00000NkvXXu0mjfv3k|u

literal 0
HcmV?d00001

diff --git a/Web/static/img/play_buttons.gif b/Web/static/img/play_buttons.gif
new file mode 100644
index 0000000000000000000000000000000000000000..b5a8078d03e13d2741b942f32fcb211317aa93d0
GIT binary patch
literal 103
zcmZ?wbhEHb6kt$bC}3bv{K>+|z`(?y!vF*zvBrdig#Z8lgSbF(3z5E=%ii)o=5|zy
z(mhxBd8g+>k4T5iWvfm`IB6ezy(X#s?SqQNi*0rVf3fRS)DYHTs@<|oTUCRB!5RSI
CaUwJT

literal 0
HcmV?d00001

diff --git a/Web/static/img/progressbar.gif b/Web/static/img/progressbar.gif
new file mode 100644
index 0000000000000000000000000000000000000000..09d6437c6fa4670ea38a1bd904994acf7266c9d4
GIT binary patch
literal 1018
zcmZ?wbhEHbjAP(u_{hjGYu2ozM~^;x_U!-v|BC;){aizWogD*Qjr0td85tND6o0a?
zaxpM6=m6OaAT10`wLSeSPrv0~JZH<T?#=gnfAGsa(CB!QK5yy5s+W}vpM136O5M@l
z*<X7i>u83{Y=afQ7EUlwJO9#Hpqo=xWQKLx<m*qjNzS;oD(dz3%D!bA(>|U#5nFxs
z%cg6(&CRb1|NZM*-u}!y<2w5bwH0nVGJc4+vmY?@jLfX;oVjvmB+Nxj3_30h%nafT
zoIn@JxAZSM_?DwlXkFWkX}LenoGqQi)}_oE_h|a8oSl=R8KJ&lX3#MQ>QMpfF=^>v
zeDJMR%N&tNhPiwbstQ$4Un{vUA}4urhWd;o<=68z+H6?+$?Ln<<F9qbL2sSUyzHCD
zgfK{pS_a8d%OC-&7zB)IX1GDRE&U4*y_K3HvVzIt{;4y|&L+w{l}zGDTRPKk`L2C=
z+H1wvx-QoB-h244X<aWYaDXubG65PhJ^hOgyya*XTGvD%W)QZTOaaGC12AS7K`~Q6
cTFfAY17!vw#SCQzA*B_{3_?o1WEo@)0B0YPK>z>%

literal 0
HcmV?d00001

diff --git a/Web/static/img/song.jpg b/Web/static/img/song.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..21e5a3a39ea15a318629c5d31996cc08d1791164
GIT binary patch
literal 2481
zcmds%dpJ~S9LL`?XD)8FVPtX}QfNfUWlN7sVML)3DO#75+qgu?B{HOvWU1KXl8M|l
z)@2ex7EPCx%VH+lL?sa!VVUIY89h(^vwx_+_SxTge&6$+^ZwrVd_T|oz6aqVFTg5G
zss$B55CmM&3m}8Q!z|3(0{|;4Kmh;<0W2f~aA*rc(-4yV(xyUc0A6~JK|8eos$dnG
z)@TaC6-~jIrTZ~AZ~tIT|D(Z*x|*9cw*aIcm;em=SqKs+Mi5w7pg1fR#^P~!{8AEx
zh42Jn0v<0UDkLl-2(%?8Dk3I$5KQuUC<X_^I1vJ#uvGF*g4_e*Lf`>-07DW0BM!mh
z5Yht3sCV3wJpsZ}Ko}T{!xK;&qG$nY6>1-bQPVioF<Kpk_5-XqPC`j*2VRoqMpzq2
z+`>r76;j?=-Xvw)Ik`^za8RtU$ZBcQ8kzMfs%jf3I=Xs4Zr!GDVroV;-(_LB&(40o
z!-0d2?njP#czSvJ91ji&JrNcj5qCQN%-M4Z=Tk3TzLJ*y%he3#jlBGu1%*Y$6_r)2
z+tqh!YJYETX}!;W@bFRBpWQvZ9PZP;!J(J0UJt(+c{|FR`Y=5+`}f>DU%&+c_%jx&
z|H>tfa$y8(KoD?2m=kEi;#i!L7G7cpjo=n2xpoUfh`2K)x4cPMS=)9}>Tpn}$m(@E
zgX?($v?a3TfyI6o*%z>{Tzx<chS0@>#eos{RK={)7EuGtnE#>hP9?aoO!?L&hVd`_
zG}lD8E1R$X^!(sKMiaM~YOH)lHD^yqua3{|T!r4ui0n=mHM)y@wE9?N{J@3M-2IE9
z2EVyRU>G*TbeH1x8inVZa%X;OYhzueM%JAhx)2ghq9hmT@<JAiihL@=`PsRF#}F`Z
z=W14?o=VARS&8bHf_blg^?h<YU)i^T_ax_zoK$RpPTYiKpwyIsL(h$##Qw+Sv#j`;
z>>~G-`3285b{sOTXr_>_^u$n_xv?wUPf`$&>N$vjk8nQ%Mp&g5>@+GbU8~V-clUMX
z&p0BW881)z(~eZ9bWc+i`EvXG8(($>@P`K;6)T>!wop|$KU1p^wm&WDMZ0uZg2{+8
zse2PKQ&?kMKD~6CqHDBDL53DtU7LQl69FFY6CI0IsKm)<T>5)rA|-G}<Y1$vbC*2(
zu_l|~+?p57UR1nX#5w8i>92o<#mG0>b9>-PZg0UtD<x#RLiJ=J0$M94=gHHAY6NuB
z9V75b^ufPYwi{P?c=_IPkJUS#MmbNEoCrK5nXYrOutZv|Fuiu~av=qEkUVE0q74Cu
z`sZmO2vDdz#nD@|&oiFOoW4F?W)oI<cAQN24!u3==vSYWr!GfxDmIr9qZIB1AedC(
zc&adYu_pPovvpfmIXCsZ$@d%z%=WUmnQMzR)l<x|MFS56l=>}{>|*c?q(+0cMt9D(
zmMuiGQHPv)8Xa}WLjXs2(f;Mq2l2$ckQrFE$Zh)LjSnN$#s{dsX-qV#$~ni1vh|a`
z=P>SG(9&x7bQQ)a_J@tKb9S@$h2<_iA((pErCctds&?#a5Ac(<3f#h$xlmb$+iUQ!
zEHOmG)1l|}hnXnH;c@=^nykElwuD=&rDl}-=gIbAWkU#PKDF||6Hc~9MXFg+Of@TW
zx00``ru5cz{GkS`;)^osp*Gd&uFTpCoR+BauVi*k8=Sm)&MFbb-?oOm@eN(aWW+Cj
z<I;>tITAPTu;wC5pY>@;sH6lvXf8C|7i7S8>}{xsqm^H?zMCAZ-O~A}q4-{`(ORFu
z^=o`2>JoTn0VmDoqHZnd^W^$eN<^!i-SpC(j#aX=NeWllG%hD!c!Rmg8H*1j9s5+`
z2s15wKS|kJo{KfLJ#eW5=x2cXK|hMsi57lnl7v6KZiESc8iuEa>*HpF=ahf-j;ILx
z*gC7>dzdN{mH9A1baPV*<KuOVF(EUP$H<Pj;!fgrWo@u{Hv34u{iDP7)xF=olPIHn
zg~TCQ?SipB`}$X^Ih0|k5JER*tKrKGmR-IYgPj~>By<`9UM|?Cx{h|ufydECmhUL_
zc^untC2m{CUD-jS74px?ldqGmx#Pyi?GGde;cbTKmIWEMqYjjJM7x-8Ia$m8*ZdbI
IB_mJ&0k$w~`v3p{

literal 0
HcmV?d00001

diff --git a/Web/static/img/tour/audios.png b/Web/static/img/tour/audios.png
new file mode 100644
index 0000000000000000000000000000000000000000..a15affe0511d972531cf450b2bb9ecac41e1a664
GIT binary patch
literal 6234
zcmeHLYi!$86uzO|)NSp?20|-H$YcnHhGRR4o!F$=N>XT{=}H^4ZbPtZ`#LpE>|ncX
zIw4pKh#~f4ynY~FW!fa*2jVF<fmnh4KmuvfU=q^)p@BB33?!t1ka%3%vFoJWD3mvi
z+NM7DobTN8yvO&U-kuv<mS4IY0MOFane79h;Z6WTr<xitW!0ML5AauGp*`0Qz^l)~
z?)LBSGa+^M<p6ka1ps?i0r2By0LC`~u=O+ut-;T!F9B%%=;LQvAHuvR)J&$gE0ZC5
z4P8--2!QoRr5!u&o&t^Co1T7ZOW(yO&+NZ&b?bqtd&b9CH@!zNhYn69J|piBUH$z?
z%f7R(MH)`;di|aKlZ~DC0`ZZ3dx^s2Qv<t-?^;dU8;|r%ymITN>F{L3c=4jWKUhsC
z8f=oZY5L?uc=E|TONej#`yPI5e0nKyUFc+D_nXf(Oliu+Z$Ez9w*1oJ73&^j8XB%h
zzrTn7yt(<On_Jfo{6rk--nn+|C8ucmo6VWsFNTJfonq+e?OPt*cFV-{*zOZkyU4>}
z?GMeLHa0hdm3OnHZ3nYwu$fDHyYn6R8-ETI4jsi`7g&8g{Xc^*-lA{9Ps_@kTTK8~
z9<hHzPi$Cu833#ItN8(IAlEHOdMN^BT||*escd6_)W%8~N<+vZL{w0<w(!?)9}W|$
z+!o%#=IC5Gg9g>kod)XP*^`%c4oSQm-k5G#o~j5Kp@b|*R7yq76e?|D2Ux)W+hi(C
zI4sssTe#iUNDSn9iHvR_f{k#b#KfXRf{!pPA5F%uC89LLQFNSQc#??;_$N*i)lV3!
zBZ!nCD?(rPhN?RJ)D|AJtg=8+qobpd(O5(`3Rs-yDLP6;qa@}a%`we_6;d<Txgb@T
zEHWiSEnBLt5jHFo^<k?mjCbssQY!n{npyQ1_8e7#Wr~T=wrLKdEctxpVWa5mK$a*}
zL?xtICgx>)-twSs>E@t*4rqV5MgewlF6Zl*b1$WmufnuCMz9%GuguA9=Eus2>O-bJ
zY)GhM1aEVlXN_s~p&6&nLmadF!j`I>L)KoYF$z`GbFkVgE~^uAJN_x`0wH4{Xz4~?
z*NbU4mwG*K61d=T?ywP-SieQpWPQ}UCPmc?*$GsOhO*E?=_nnIku*cnY@SI9Y(j|9
z8|auo(*X#Vvj!oj%c?Rq3xUUi0>cCm0-Uu7xI$!T!Fgc>d3_XF5)|DiK`X76U;$BO
zt&pN-h(4d6SVBfG>IU8rO2<;vTx4IyNjsrSGcBk|s4JVsgovuj0wY5x!l+ELQAH*b
z@i;?@VmwMFrI->$GEK)47<-m@9dK5chV25uz;4TsbqRydY>DQ$q>_LNnUt9*$uLO9
zYB)rqq?lki7BVyxJv9NaIyIXO6*n|k3~<<X$XFGJH!H`)7>O93CS@@$lf1%6Bovbh
zuOME8yyPjE1Ex+<r)uKt9P@Lw+GYCDT>(;+SX7;kQ-+3#>{@IKd&TH=C^u6vyHm~r
zl<YQ@mh2Kha*C><dK;-`x4Wj!j@$g~oh3de{OBOk&Q~}tio-Rhys0bJC^S%e0jI$H
z%Grgi6%1mhD7%Xn)j!bPx;BE0c{&^i6XGKe8D?5B^b+A0Bw^_UESHO_1nnSFBbr=2
zP5fN<0vf>hb1gIB$nQ?0Bo~V@B1)18jzJ`k;z<(5C6-Jk6C9G+gs5=1E`MhlIo*@y
zyzcooq>*29(je3dNKR9pF&^Qc8=AOC!$eYvk-W@_I3PGQJYpe9iwT<JnV5vQq-RZ_
zK?Du*&7W1_bqCx3Ff`Ia`oXWX0J}^0EyIh^U)B#~WT^A(2!TEM{ve!=_m8%4Asugi
z1J6P_-psuCZ!9bK<PA<b-q=(>6xoA(E+=H&bi2VmC+$NU=hXQIAv^@(w+tcBvAj`q
zz74NVe5T=5q4DhL4WqwpI&^my5T6CDFLVHPRm=h7sq&m1@TxxprR*0k0Z*@Z%yPz$
zG?Suc(R?IlWxyUri^I;u$#XRRP>lVzDi~a@72JA$!QgVO;Edd@nrfw^RZVqdU&ekz
zbYEop@vQ|)b1WNUQ&c^<mcLi6pyEJQ#qS)ZJxeDO@v2Du${ccAXXN5Ze1tlx9J#=X
zE9VwN;Bvu<g-gXZ7x)Rs%eywQpZu#Aq~ZG(17ECM^}w->04!<k%C_e#w}15s69S<5
zgUREEPapbl$>j$_q2t8!mtL8_@BTaY_O`5fsr`Dk^M$3$7Kw`vSxkn-Lin$DhNj}G
ZH_lEBoxS7mG5hMetD`6T^7T6=egWw+lqvuK

literal 0
HcmV?d00001

diff --git a/Web/static/img/tour/audios_playlists.png b/Web/static/img/tour/audios_playlists.png
new file mode 100644
index 0000000000000000000000000000000000000000..a15affe0511d972531cf450b2bb9ecac41e1a664
GIT binary patch
literal 6234
zcmeHLYi!$86uzO|)NSp?20|-H$YcnHhGRR4o!F$=N>XT{=}H^4ZbPtZ`#LpE>|ncX
zIw4pKh#~f4ynY~FW!fa*2jVF<fmnh4KmuvfU=q^)p@BB33?!t1ka%3%vFoJWD3mvi
z+NM7DobTN8yvO&U-kuv<mS4IY0MOFane79h;Z6WTr<xitW!0ML5AauGp*`0Qz^l)~
z?)LBSGa+^M<p6ka1ps?i0r2By0LC`~u=O+ut-;T!F9B%%=;LQvAHuvR)J&$gE0ZC5
z4P8--2!QoRr5!u&o&t^Co1T7ZOW(yO&+NZ&b?bqtd&b9CH@!zNhYn69J|piBUH$z?
z%f7R(MH)`;di|aKlZ~DC0`ZZ3dx^s2Qv<t-?^;dU8;|r%ymITN>F{L3c=4jWKUhsC
z8f=oZY5L?uc=E|TONej#`yPI5e0nKyUFc+D_nXf(Oliu+Z$Ez9w*1oJ73&^j8XB%h
zzrTn7yt(<On_Jfo{6rk--nn+|C8ucmo6VWsFNTJfonq+e?OPt*cFV-{*zOZkyU4>}
z?GMeLHa0hdm3OnHZ3nYwu$fDHyYn6R8-ETI4jsi`7g&8g{Xc^*-lA{9Ps_@kTTK8~
z9<hHzPi$Cu833#ItN8(IAlEHOdMN^BT||*escd6_)W%8~N<+vZL{w0<w(!?)9}W|$
z+!o%#=IC5Gg9g>kod)XP*^`%c4oSQm-k5G#o~j5Kp@b|*R7yq76e?|D2Ux)W+hi(C
zI4sssTe#iUNDSn9iHvR_f{k#b#KfXRf{!pPA5F%uC89LLQFNSQc#??;_$N*i)lV3!
zBZ!nCD?(rPhN?RJ)D|AJtg=8+qobpd(O5(`3Rs-yDLP6;qa@}a%`we_6;d<Txgb@T
zEHWiSEnBLt5jHFo^<k?mjCbssQY!n{npyQ1_8e7#Wr~T=wrLKdEctxpVWa5mK$a*}
zL?xtICgx>)-twSs>E@t*4rqV5MgewlF6Zl*b1$WmufnuCMz9%GuguA9=Eus2>O-bJ
zY)GhM1aEVlXN_s~p&6&nLmadF!j`I>L)KoYF$z`GbFkVgE~^uAJN_x`0wH4{Xz4~?
z*NbU4mwG*K61d=T?ywP-SieQpWPQ}UCPmc?*$GsOhO*E?=_nnIku*cnY@SI9Y(j|9
z8|auo(*X#Vvj!oj%c?Rq3xUUi0>cCm0-Uu7xI$!T!Fgc>d3_XF5)|DiK`X76U;$BO
zt&pN-h(4d6SVBfG>IU8rO2<;vTx4IyNjsrSGcBk|s4JVsgovuj0wY5x!l+ELQAH*b
z@i;?@VmwMFrI->$GEK)47<-m@9dK5chV25uz;4TsbqRydY>DQ$q>_LNnUt9*$uLO9
zYB)rqq?lki7BVyxJv9NaIyIXO6*n|k3~<<X$XFGJH!H`)7>O93CS@@$lf1%6Bovbh
zuOME8yyPjE1Ex+<r)uKt9P@Lw+GYCDT>(;+SX7;kQ-+3#>{@IKd&TH=C^u6vyHm~r
zl<YQ@mh2Kha*C><dK;-`x4Wj!j@$g~oh3de{OBOk&Q~}tio-Rhys0bJC^S%e0jI$H
z%Grgi6%1mhD7%Xn)j!bPx;BE0c{&^i6XGKe8D?5B^b+A0Bw^_UESHO_1nnSFBbr=2
zP5fN<0vf>hb1gIB$nQ?0Bo~V@B1)18jzJ`k;z<(5C6-Jk6C9G+gs5=1E`MhlIo*@y
zyzcooq>*29(je3dNKR9pF&^Qc8=AOC!$eYvk-W@_I3PGQJYpe9iwT<JnV5vQq-RZ_
zK?Du*&7W1_bqCx3Ff`Ia`oXWX0J}^0EyIh^U)B#~WT^A(2!TEM{ve!=_m8%4Asugi
z1J6P_-psuCZ!9bK<PA<b-q=(>6xoA(E+=H&bi2VmC+$NU=hXQIAv^@(w+tcBvAj`q
zz74NVe5T=5q4DhL4WqwpI&^my5T6CDFLVHPRm=h7sq&m1@TxxprR*0k0Z*@Z%yPz$
zG?Suc(R?IlWxyUri^I;u$#XRRP>lVzDi~a@72JA$!QgVO;Edd@nrfw^RZVqdU&ekz
zbYEop@vQ|)b1WNUQ&c^<mcLi6pyEJQ#qS)ZJxeDO@v2Du${ccAXXN5Ze1tlx9J#=X
zE9VwN;Bvu<g-gXZ7x)Rs%eywQpZu#Aq~ZG(17ECM^}w->04!<k%C_e#w}15s69S<5
zgUREEPapbl$>j$_q2t8!mtL8_@BTaY_O`5fsr`Dk^M$3$7Kw`vSxkn-Lin$DhNj}G
ZH_lEBoxS7mG5hMetD`6T^7T6=egWw+lqvuK

literal 0
HcmV?d00001

diff --git a/Web/static/img/tour/audios_search.png b/Web/static/img/tour/audios_search.png
new file mode 100644
index 0000000000000000000000000000000000000000..a15affe0511d972531cf450b2bb9ecac41e1a664
GIT binary patch
literal 6234
zcmeHLYi!$86uzO|)NSp?20|-H$YcnHhGRR4o!F$=N>XT{=}H^4ZbPtZ`#LpE>|ncX
zIw4pKh#~f4ynY~FW!fa*2jVF<fmnh4KmuvfU=q^)p@BB33?!t1ka%3%vFoJWD3mvi
z+NM7DobTN8yvO&U-kuv<mS4IY0MOFane79h;Z6WTr<xitW!0ML5AauGp*`0Qz^l)~
z?)LBSGa+^M<p6ka1ps?i0r2By0LC`~u=O+ut-;T!F9B%%=;LQvAHuvR)J&$gE0ZC5
z4P8--2!QoRr5!u&o&t^Co1T7ZOW(yO&+NZ&b?bqtd&b9CH@!zNhYn69J|piBUH$z?
z%f7R(MH)`;di|aKlZ~DC0`ZZ3dx^s2Qv<t-?^;dU8;|r%ymITN>F{L3c=4jWKUhsC
z8f=oZY5L?uc=E|TONej#`yPI5e0nKyUFc+D_nXf(Oliu+Z$Ez9w*1oJ73&^j8XB%h
zzrTn7yt(<On_Jfo{6rk--nn+|C8ucmo6VWsFNTJfonq+e?OPt*cFV-{*zOZkyU4>}
z?GMeLHa0hdm3OnHZ3nYwu$fDHyYn6R8-ETI4jsi`7g&8g{Xc^*-lA{9Ps_@kTTK8~
z9<hHzPi$Cu833#ItN8(IAlEHOdMN^BT||*escd6_)W%8~N<+vZL{w0<w(!?)9}W|$
z+!o%#=IC5Gg9g>kod)XP*^`%c4oSQm-k5G#o~j5Kp@b|*R7yq76e?|D2Ux)W+hi(C
zI4sssTe#iUNDSn9iHvR_f{k#b#KfXRf{!pPA5F%uC89LLQFNSQc#??;_$N*i)lV3!
zBZ!nCD?(rPhN?RJ)D|AJtg=8+qobpd(O5(`3Rs-yDLP6;qa@}a%`we_6;d<Txgb@T
zEHWiSEnBLt5jHFo^<k?mjCbssQY!n{npyQ1_8e7#Wr~T=wrLKdEctxpVWa5mK$a*}
zL?xtICgx>)-twSs>E@t*4rqV5MgewlF6Zl*b1$WmufnuCMz9%GuguA9=Eus2>O-bJ
zY)GhM1aEVlXN_s~p&6&nLmadF!j`I>L)KoYF$z`GbFkVgE~^uAJN_x`0wH4{Xz4~?
z*NbU4mwG*K61d=T?ywP-SieQpWPQ}UCPmc?*$GsOhO*E?=_nnIku*cnY@SI9Y(j|9
z8|auo(*X#Vvj!oj%c?Rq3xUUi0>cCm0-Uu7xI$!T!Fgc>d3_XF5)|DiK`X76U;$BO
zt&pN-h(4d6SVBfG>IU8rO2<;vTx4IyNjsrSGcBk|s4JVsgovuj0wY5x!l+ELQAH*b
z@i;?@VmwMFrI->$GEK)47<-m@9dK5chV25uz;4TsbqRydY>DQ$q>_LNnUt9*$uLO9
zYB)rqq?lki7BVyxJv9NaIyIXO6*n|k3~<<X$XFGJH!H`)7>O93CS@@$lf1%6Bovbh
zuOME8yyPjE1Ex+<r)uKt9P@Lw+GYCDT>(;+SX7;kQ-+3#>{@IKd&TH=C^u6vyHm~r
zl<YQ@mh2Kha*C><dK;-`x4Wj!j@$g~oh3de{OBOk&Q~}tio-Rhys0bJC^S%e0jI$H
z%Grgi6%1mhD7%Xn)j!bPx;BE0c{&^i6XGKe8D?5B^b+A0Bw^_UESHO_1nnSFBbr=2
zP5fN<0vf>hb1gIB$nQ?0Bo~V@B1)18jzJ`k;z<(5C6-Jk6C9G+gs5=1E`MhlIo*@y
zyzcooq>*29(je3dNKR9pF&^Qc8=AOC!$eYvk-W@_I3PGQJYpe9iwT<JnV5vQq-RZ_
zK?Du*&7W1_bqCx3Ff`Ia`oXWX0J}^0EyIh^U)B#~WT^A(2!TEM{ve!=_m8%4Asugi
z1J6P_-psuCZ!9bK<PA<b-q=(>6xoA(E+=H&bi2VmC+$NU=hXQIAv^@(w+tcBvAj`q
zz74NVe5T=5q4DhL4WqwpI&^my5T6CDFLVHPRm=h7sq&m1@TxxprR*0k0Z*@Z%yPz$
zG?Suc(R?IlWxyUri^I;u$#XRRP>lVzDi~a@72JA$!QgVO;Edd@nrfw^RZVqdU&ekz
zbYEop@vQ|)b1WNUQ&c^<mcLi6pyEJQ#qS)ZJxeDO@v2Du${ccAXXN5Ze1tlx9J#=X
zE9VwN;Bvu<g-gXZ7x)Rs%eywQpZu#Aq~ZG(17ECM^}w->04!<k%C_e#w}15s69S<5
zgUREEPapbl$>j$_q2t8!mtL8_@BTaY_O`5fsr`Dk^M$3$7Kw`vSxkn-Lin$DhNj}G
ZH_lEBoxS7mG5hMetD`6T^7T6=egWw+lqvuK

literal 0
HcmV?d00001

diff --git a/Web/static/img/tour/audios_upload.png b/Web/static/img/tour/audios_upload.png
new file mode 100644
index 0000000000000000000000000000000000000000..a15affe0511d972531cf450b2bb9ecac41e1a664
GIT binary patch
literal 6234
zcmeHLYi!$86uzO|)NSp?20|-H$YcnHhGRR4o!F$=N>XT{=}H^4ZbPtZ`#LpE>|ncX
zIw4pKh#~f4ynY~FW!fa*2jVF<fmnh4KmuvfU=q^)p@BB33?!t1ka%3%vFoJWD3mvi
z+NM7DobTN8yvO&U-kuv<mS4IY0MOFane79h;Z6WTr<xitW!0ML5AauGp*`0Qz^l)~
z?)LBSGa+^M<p6ka1ps?i0r2By0LC`~u=O+ut-;T!F9B%%=;LQvAHuvR)J&$gE0ZC5
z4P8--2!QoRr5!u&o&t^Co1T7ZOW(yO&+NZ&b?bqtd&b9CH@!zNhYn69J|piBUH$z?
z%f7R(MH)`;di|aKlZ~DC0`ZZ3dx^s2Qv<t-?^;dU8;|r%ymITN>F{L3c=4jWKUhsC
z8f=oZY5L?uc=E|TONej#`yPI5e0nKyUFc+D_nXf(Oliu+Z$Ez9w*1oJ73&^j8XB%h
zzrTn7yt(<On_Jfo{6rk--nn+|C8ucmo6VWsFNTJfonq+e?OPt*cFV-{*zOZkyU4>}
z?GMeLHa0hdm3OnHZ3nYwu$fDHyYn6R8-ETI4jsi`7g&8g{Xc^*-lA{9Ps_@kTTK8~
z9<hHzPi$Cu833#ItN8(IAlEHOdMN^BT||*escd6_)W%8~N<+vZL{w0<w(!?)9}W|$
z+!o%#=IC5Gg9g>kod)XP*^`%c4oSQm-k5G#o~j5Kp@b|*R7yq76e?|D2Ux)W+hi(C
zI4sssTe#iUNDSn9iHvR_f{k#b#KfXRf{!pPA5F%uC89LLQFNSQc#??;_$N*i)lV3!
zBZ!nCD?(rPhN?RJ)D|AJtg=8+qobpd(O5(`3Rs-yDLP6;qa@}a%`we_6;d<Txgb@T
zEHWiSEnBLt5jHFo^<k?mjCbssQY!n{npyQ1_8e7#Wr~T=wrLKdEctxpVWa5mK$a*}
zL?xtICgx>)-twSs>E@t*4rqV5MgewlF6Zl*b1$WmufnuCMz9%GuguA9=Eus2>O-bJ
zY)GhM1aEVlXN_s~p&6&nLmadF!j`I>L)KoYF$z`GbFkVgE~^uAJN_x`0wH4{Xz4~?
z*NbU4mwG*K61d=T?ywP-SieQpWPQ}UCPmc?*$GsOhO*E?=_nnIku*cnY@SI9Y(j|9
z8|auo(*X#Vvj!oj%c?Rq3xUUi0>cCm0-Uu7xI$!T!Fgc>d3_XF5)|DiK`X76U;$BO
zt&pN-h(4d6SVBfG>IU8rO2<;vTx4IyNjsrSGcBk|s4JVsgovuj0wY5x!l+ELQAH*b
z@i;?@VmwMFrI->$GEK)47<-m@9dK5chV25uz;4TsbqRydY>DQ$q>_LNnUt9*$uLO9
zYB)rqq?lki7BVyxJv9NaIyIXO6*n|k3~<<X$XFGJH!H`)7>O93CS@@$lf1%6Bovbh
zuOME8yyPjE1Ex+<r)uKt9P@Lw+GYCDT>(;+SX7;kQ-+3#>{@IKd&TH=C^u6vyHm~r
zl<YQ@mh2Kha*C><dK;-`x4Wj!j@$g~oh3de{OBOk&Q~}tio-Rhys0bJC^S%e0jI$H
z%Grgi6%1mhD7%Xn)j!bPx;BE0c{&^i6XGKe8D?5B^b+A0Bw^_UESHO_1nnSFBbr=2
zP5fN<0vf>hb1gIB$nQ?0Bo~V@B1)18jzJ`k;z<(5C6-Jk6C9G+gs5=1E`MhlIo*@y
zyzcooq>*29(je3dNKR9pF&^Qc8=AOC!$eYvk-W@_I3PGQJYpe9iwT<JnV5vQq-RZ_
zK?Du*&7W1_bqCx3Ff`Ia`oXWX0J}^0EyIh^U)B#~WT^A(2!TEM{ve!=_m8%4Asugi
z1J6P_-psuCZ!9bK<PA<b-q=(>6xoA(E+=H&bi2VmC+$NU=hXQIAv^@(w+tcBvAj`q
zz74NVe5T=5q4DhL4WqwpI&^my5T6CDFLVHPRm=h7sq&m1@TxxprR*0k0Z*@Z%yPz$
zG?Suc(R?IlWxyUri^I;u$#XRRP>lVzDi~a@72JA$!QgVO;Edd@nrfw^RZVqdU&ekz
zbYEop@vQ|)b1WNUQ&c^<mcLi6pyEJQ#qS)ZJxeDO@v2Du${ccAXXN5Ze1tlx9J#=X
zE9VwN;Bvu<g-gXZ7x)Rs%eywQpZu#Aq~ZG(17ECM^}w->04!<k%C_e#w}15s69S<5
zgUREEPapbl$>j$_q2t8!mtL8_@BTaY_O`5fsr`Dk^M$3$7Kw`vSxkn-Lin$DhNj}G
ZH_lEBoxS7mG5hMetD`6T^7T6=egWw+lqvuK

literal 0
HcmV?d00001

diff --git a/Web/static/js/al_music.js b/Web/static/js/al_music.js
new file mode 100644
index 00000000..fbbe7c82
--- /dev/null
+++ b/Web/static/js/al_music.js
@@ -0,0 +1,1433 @@
+function fmtTime(time) {
+    const mins = String(Math.floor(time / 60)).padStart(2, '0');
+    const secs = String(Math.floor(time % 60)).padStart(2, '0');
+    return `${ mins}:${ secs}`;
+}
+
+function fastError(message) {
+    MessageBox(tr("error"), message, [tr("ok")], [Function.noop])
+}
+
+// elapsed это вроде прошедшие, а оставшееся это remaining но ладно уже
+function getElapsedTime(fullTime, time) {
+    let timer = fullTime - time
+
+    if(timer < 0) return "-00:00"
+
+    return "-" + fmtTime(timer)
+}
+
+window.savedAudiosPages = {}
+
+class playersSearcher {
+    constructor(context_type, context_id) {
+        this.context_type = context_type
+        this.context_id = context_id
+        this.searchType = "by_name"
+        this.query = ""
+        this.page = 1
+        this.successCallback = () => {}
+        this.errorCallback = () => {}
+        this.beforesendCallback = () => {}
+        this.clearContainer = () => {}
+    }
+
+    execute() {
+        $.ajax({
+            type: "POST",
+            url: "/audios/context",
+            data: {
+                context: this.context_type,
+                hash: u("meta[name=csrf]").attr("value"),
+                page: this.page,
+                query: this.query,
+                context_entity: this.context_id,
+                type: this.searchType,
+                returnPlayers: 1,
+            },
+            beforeSend: () => {
+                this.beforesendCallback()
+            },
+            error: () => {
+                this.errorCallback()
+            },
+            success: (response) => {
+                this.successCallback(response, this)
+            }
+        })
+    }
+
+    movePage(page) {
+        this.page = page
+        this.execute()
+    }
+}
+
+class bigPlayer {
+    tracks = {
+        currentTrack: null,
+        nextTrack: null,
+        previousTrack: null,
+        tracks: []
+    }
+
+    context = {
+        context_type: null,
+        context_id: 0,
+        pagesCount: 0,
+        playedPages: [],
+        object: [],
+    }
+
+    nodes = {
+        dashPlayer: null,
+        audioPlayer: null,
+        thisPlayer: null,
+        playButtons: null,
+    }
+
+    timeType = 0
+
+    findTrack(id) {
+        return this.tracks["tracks"].find(item => item.id == id)
+    }
+
+    constructor(context, context_id, page = 1) {
+        this.context["context_type"] = context
+        this.context["context_id"] = context_id
+        this.context["playedPages"].push(String(page))
+
+        this.nodes["thisPlayer"] = document.querySelector(".bigPlayer")
+        this.nodes["thisPlayer"].classList.add("lagged")
+        this.nodes["audioPlayer"] = document.createElement("audio")
+
+        this.player = () => { return this.nodes["audioPlayer"] }
+        this.nodes["playButtons"] = this.nodes["thisPlayer"].querySelector(".playButtons")
+        this.nodes["dashPlayer"] = dashjs.MediaPlayer().create()
+
+        let formdata = new FormData()
+        formdata.append("context", context)
+        formdata.append("context_entity", context_id)
+        formdata.append("query", context_id)
+        formdata.append("hash", u("meta[name=csrf]").attr("value"))
+        formdata.append("page", page)
+    
+        ky.post("/audios/context", {
+            hooks: {
+                afterResponse: [
+                    async (_request, _options, response) => {
+                        if(response.status !== 200) {
+                            fastError(tr("unable_to_load_queue"))
+                            return
+                        }
+
+                        let contextObject = await response.json()
+
+                        if(!contextObject.success) {
+                            fastError(tr("unable_to_load_queue"))
+                            return
+                        }
+
+                        this.nodes["thisPlayer"].classList.remove("lagged")
+                        this.tracks["tracks"] = contextObject["items"]
+                        this.context["pagesCount"] = contextObject["pagesCount"]
+
+                        if(localStorage.lastPlayedTrack != null && this.tracks["tracks"].find(item => item.id == localStorage.lastPlayedTrack) != null) {
+                            this.setTrack(localStorage.lastPlayedTrack)
+                            this.pause()
+                        }
+
+                        console.info("Context is successfully loaded")
+                    }
+                ]
+            }, 
+            body: formdata
+        })
+
+        u(this.nodes["playButtons"].querySelector(".playButton")).on("click", (e) => {
+            if(this.player().paused)
+                this.play()
+            else
+                this.pause()
+        })
+
+        u(this.player()).on("timeupdate", (e) => {
+            const time = this.player().currentTime;
+            const ps = ((time * 100) / this.tracks["currentTrack"].length).toFixed(3)
+            this.nodes["thisPlayer"].querySelector(".time").innerHTML = fmtTime(time)
+            this.timeType == 0 ? this.nodes["thisPlayer"].querySelector(".elapsedTime").innerHTML = getElapsedTime(this.tracks["currentTrack"].length, time)
+                : null
+
+            if (ps <= 100)
+                this.nodes["thisPlayer"].querySelector(".selectableTrack .slider").style.left = `${ ps}%`;
+
+        })
+
+        u(this.player()).on("volumechange", (e) => {
+            const volume = this.player().volume;
+            const ps = Math.ceil((volume * 100) / 1);
+
+            if (ps <= 100)
+                this.nodes["thisPlayer"].querySelector(".volumePanel .selectableTrack .slider").style.left = `${ ps}%`;
+            
+            localStorage.volume = volume
+        })
+
+        u(".bigPlayer .track > div").on("click mouseup", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            let rect  = this.nodes["thisPlayer"].querySelector(".selectableTrack").getBoundingClientRect();
+            
+            const width = e.clientX - rect.left;
+            const time = Math.ceil((width * this.tracks["currentTrack"].length) / (rect.right - rect.left));
+
+            this.player().currentTime = time;
+        })
+
+        u(".bigPlayer .trackPanel .selectableTrack").on("mousemove", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            let rect  = this.nodes["thisPlayer"].querySelector(".selectableTrack").getBoundingClientRect();
+            
+            const width = e.clientX - rect.left;
+            const time = Math.ceil((width * this.tracks["currentTrack"].length) / (rect.right - rect.left));
+
+            document.querySelector(".bigPlayer .track .bigPlayerTip").style.display = "block"
+            document.querySelector(".bigPlayer .track .bigPlayerTip").innerHTML = fmtTime(time)
+            document.querySelector(".bigPlayer .track .bigPlayerTip").style.left = `min(${width - 15}px, 315.5px)`
+        })
+
+        u(".bigPlayer .nextButton").on("mouseover mouseleave", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            if(e.type == "mouseleave") {
+                $(".nextTrackTip").remove()
+                return
+            }
+
+            e.currentTarget.parentNode.insertAdjacentHTML("afterbegin", `
+                <div class="bigPlayerTip nextTrackTip" style="left: 5%;">
+                    ${ovk_proc_strtr(escapeHtml(this.findTrack(this.tracks["previousTrack"]).name), 20) ?? ""}
+                </div>
+            `)
+
+            document.querySelector(".nextTrackTip").style.display = "block"
+        })
+
+        u(".bigPlayer .backButton").on("mouseover mouseleave", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            if(e.type == "mouseleave") {
+                $(".previousTrackTip").remove()
+                return
+            }
+
+            e.currentTarget.parentNode.insertAdjacentHTML("afterbegin", `
+                <div class="bigPlayerTip previousTrackTip" style="left: 8%;">
+                    ${ovk_proc_strtr(escapeHtml(this.findTrack(this.tracks["nextTrack"]).name), 20) ?? ""}
+                </div>
+            `)
+
+            document.querySelector(".previousTrackTip").style.display = "block"
+        })
+
+        u(".bigPlayer .trackPanel .selectableTrack").on("mouseleave", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+            
+            document.querySelector(".bigPlayer .track .bigPlayerTip").style.display = "none"
+        })
+
+        u(".bigPlayer .volumePanel > div").on("click mouseup mousemove", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            if(e.type == "mousemove") {
+                let buttonsPresseed = _bsdnUnwrapBitMask(e.buttons)
+                if(!buttonsPresseed[0])
+                    return;
+            }
+
+            let rect  = this.nodes["thisPlayer"].querySelector(".volumePanel .selectableTrack").getBoundingClientRect();
+            
+            const width = e.clientX - rect.left;
+            const volume = Math.max(0, (width * 1) / (rect.right - rect.left));
+
+            this.player().volume = volume;
+        })
+
+        u(".bigPlayer .elapsedTime").on("click", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            this.timeType == 0 ? (this.timeType = 1) : (this.timeType = 0)
+
+            localStorage.playerTimeType = this.timeType
+
+            this.nodes["thisPlayer"].querySelector(".elapsedTime").innerHTML = this.timeType == 1 ? 
+                fmtTime(this.tracks["currentTrack"].length) 
+                : getElapsedTime(this.tracks["currentTrack"].length, this.player().currentTime)
+        })
+
+        u(".bigPlayer .additionalButtons .repeatButton").on("click", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            e.currentTarget.classList.toggle("pressed")
+
+            if(e.currentTarget.classList.contains("pressed"))
+                this.player().loop = true
+            else
+                this.player().loop = false
+        })
+
+        u(".bigPlayer .additionalButtons .shuffleButton").on("click", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            this.tracks["tracks"].sort(() => Math.random() - 0.59)
+            this.setTrack(this.tracks["tracks"].at(0).id)
+        })
+
+        // хз что она делала в самом вк, но тут сделаем вид что это просто мут музыки
+        u(".bigPlayer .additionalButtons .deviceButton").on("click", (e) => {
+            if(this.tracks["currentTrack"] == null)
+                return
+
+            e.currentTarget.classList.toggle("pressed")
+
+            this.player().muted = e.currentTarget.classList.contains("pressed")
+        })
+
+        u(".bigPlayer .arrowsButtons .nextButton").on("click", (e) => {
+            this.showPreviousTrack()
+        })
+
+        u(".bigPlayer .arrowsButtons .backButton").on("click", (e) => {
+            this.showNextTrack()
+        })
+
+        u(".bigPlayer .trackInfo b").on("click", (e) => {
+            window.location.assign(`/search?query=${e.currentTarget.innerHTML}&type=audios&only_performers=on`)
+        })
+
+        u(document).on("keydown", (e) => {
+            if(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "].includes(e.key)) {
+                if(document.querySelector(".ovk-diag-cont") != null)
+                    return
+
+                e.preventDefault()
+            }
+
+            switch(e.key) {
+                case "ArrowUp":
+                    this.player().volume = Math.min(0.99, this.player().volume + 0.1)
+                    break
+                case "ArrowDown":
+                    this.player().volume = Math.max(0, this.player().volume - 0.1)
+                    break
+                case "ArrowLeft":
+                    this.player().currentTime = this.player().currentTime - 3
+                    break
+                case "ArrowRight":
+                    this.player().currentTime = this.player().currentTime + 3
+                    break
+                // буквально
+                case " ":
+                    if(this.player().paused)
+                        this.play()
+                    else 
+                        this.pause()
+
+                    break;
+            }
+        })
+
+        u(document).on("keyup", (e) => {
+            if([87, 65, 83, 68, 82, 77].includes(e.keyCode)) {
+                if(document.querySelector(".ovk-diag-cont") != null)
+                    return
+
+                e.preventDefault()
+            }
+
+            switch(e.keyCode) {
+                case 87:
+                case 65:
+                    this.showPreviousTrack()
+                    break
+                case 83:
+                case 68:
+                    this.showNextTrack()
+                    break
+                case 82:
+                    document.querySelector(".bigPlayer .additionalButtons .repeatButton").click()
+                    break
+                case 77:
+                    document.querySelector(".bigPlayer .additionalButtons .deviceButton").click()
+                    break
+            }
+        })
+
+        u(this.player()).on("ended", (e) => {
+            e.preventDefault()
+            
+            // в начало очереди
+            if(!this.tracks.nextTrack) {
+                if(!this.context["playedPages"].includes("1")) {
+                    $.ajax({
+                        type: "POST",
+                        url: "/audios/context",
+                        data: {
+                            context: this["context"].context_type,
+                            context_entity: this["context"].context_id,
+                            hash: u("meta[name=csrf]").attr("value"),
+                            page: 1
+                        },
+                        success: (response_2) => {
+                            this.tracks["tracks"] = response_2["items"].concat(this.tracks["tracks"])
+                            this.context["playedPages"].push(String(1))
+
+                            this.setTrack(this.tracks["tracks"][0].id)
+                        }
+                    })
+                } else {
+                    this.setTrack(this.tracks.tracks[0].id)
+                }
+                
+                return
+            }
+
+            this.showNextTrack()
+        })
+        
+        u(this.player()).on("loadstart", (e) => {
+            let playlist = this.context.context_type == "playlist_context" ? this.context.context_id : null
+
+            let tempThisId = this.tracks.currentTrack.id
+            setTimeout(() => {
+                if(tempThisId != this.tracks.currentTrack.id) return
+
+                $.ajax({
+                    type: "POST",
+                    url: `/audio${this.tracks["currentTrack"].id}/listen`,
+                    data: {
+                        hash: u("meta[name=csrf]").attr("value"),
+                        playlist: playlist
+                    },
+                    success: (response) => {
+                        if(response.success) {
+                            console.info("Listen is counted.")
+        
+                            if(response.new_playlists_listens)
+                                document.querySelector("#listensCount").innerHTML = tr("listens_count", response.new_playlists_listens)
+                        } else
+                            console.info("Listen is not counted.")
+                    }
+                })
+            }, 2000)
+        })
+
+        if(localStorage.volume != null && localStorage.volume < 1 && localStorage.volume > 0)
+            this.player().volume = localStorage.volume
+        else
+            this.player().volume = 0.75
+
+        if(localStorage.playerTimeType == 'null' || localStorage.playerTimeType == null)
+            this.timeType = 0
+        else
+            this.timeType = localStorage.playerTimeType
+
+        navigator.mediaSession.setActionHandler('play', () => { this.play() });
+        navigator.mediaSession.setActionHandler('pause', () => { this.pause() });
+        navigator.mediaSession.setActionHandler('previoustrack', () => { this.showPreviousTrack() });
+        navigator.mediaSession.setActionHandler('nexttrack', () => { this.showNextTrack() });
+        navigator.mediaSession.setActionHandler("seekto", (details) => {
+            this.player().currentTime = details.seekTime;
+        });
+    }
+
+    play() {
+        if(this.tracks["currentTrack"] == null)
+            return
+
+        document.querySelectorAll('audio').forEach(el => el.pause());
+        document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry .playerButton .playIcon`) != null ? document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry .playerButton .playIcon`).classList.add("paused") : void(0)
+        this.player().play()
+        this.nodes["playButtons"].querySelector(".playButton").classList.add("pause")
+        document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_paused.png")
+    
+        navigator.mediaSession.playbackState = "playing"
+    }
+    
+    pause() {
+        if(this.tracks["currentTrack"] == null) 
+            return
+
+        document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry .playerButton .playIcon`) != null ? document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry .playerButton .playIcon`).classList.remove("paused") : void(0)
+        this.player().pause()
+        this.nodes["playButtons"].querySelector(".playButton").classList.remove("pause")
+        document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_playing.png")
+    
+        navigator.mediaSession.playbackState = "paused"
+    }
+
+    showPreviousTrack() {
+        if(this.tracks["currentTrack"] == null || this.tracks["previousTrack"] == null)
+            return
+
+        this.setTrack(this.tracks["previousTrack"])
+    }
+
+    showNextTrack() {
+        if(this.tracks["currentTrack"] == null || this.tracks["nextTrack"] == null)
+            return
+        
+        this.setTrack(this.tracks["nextTrack"])
+    }
+
+    updateButtons() {
+        // перепутал некст и бек.
+        let prevButton = this.nodes["thisPlayer"].querySelector(".nextButton")
+        let nextButton = this.nodes["thisPlayer"].querySelector(".backButton")
+
+        if(this.tracks["previousTrack"] == null)
+            prevButton.classList.add("lagged")
+        else
+            prevButton.classList.remove("lagged")
+
+        if(this.tracks["nextTrack"] == null)
+            nextButton.classList.add("lagged")
+        else 
+            nextButton.classList.remove("lagged")
+
+        if(document.querySelector(".nextTrackTip") != null) {
+            let track = this.findTrack(this.tracks["previousTrack"])
+            document.querySelector(".nextTrackTip").innerHTML = `
+                ${track != null ? ovk_proc_strtr(escapeHtml(track.name), 20) : ""}
+            `
+        }
+
+        if(document.querySelector(".previousTrackTip") != null) {
+            let track = this.findTrack(this.tracks["nextTrack"])
+            document.querySelector(".previousTrackTip").innerHTML = `
+                ${track != null ? ovk_proc_strtr(escapeHtml(track.name ?? ""), 20) : ""}
+            `
+        }
+    }
+
+    setTrack(id) {
+        if(this.tracks["tracks"] == null) {
+            console.info("Context is not loaded yet. Wait please")
+            return 0;
+        }
+
+        document.querySelectorAll(".audioEntry.nowPlaying").forEach(el => el.classList.remove("nowPlaying"))
+        let obj = this.tracks["tracks"].find(item => item.id == id)
+
+        if(obj == null) {
+            fastError("No audio in context")
+            return
+        }
+
+        this.nodes["thisPlayer"].querySelector(".trackInfo span").innerHTML = escapeHtml(obj.name) 
+        this.nodes["thisPlayer"].querySelector(".trackInfo b").innerHTML = escapeHtml(obj.performer)
+        this.nodes["thisPlayer"].querySelector(".trackInfo .time").innerHTML = fmtTime(obj.length)
+        this.tracks["currentTrack"] = obj
+
+        let indexOfCurrentTrack = this.tracks["tracks"].indexOf(obj) ?? 0
+        this.tracks["nextTrack"] = this.tracks["tracks"].at(indexOfCurrentTrack + 1) != null ? this.tracks["tracks"].at(indexOfCurrentTrack + 1).id : null
+
+        if(indexOfCurrentTrack - 1 >= 0)
+            this.tracks["previousTrack"] = this.tracks["tracks"].at(indexOfCurrentTrack - 1).id
+        else
+            this.tracks["previousTrack"] = null
+
+        if(this.tracks["nextTrack"] == null && Math.max(...this.context["playedPages"]) < this.context["pagesCount"]
+            || this.tracks["previousTrack"] == null && (Math.min(...this.context["playedPages"]) > 1)) {
+            
+            // idk how it works
+            let lesser = this.tracks["previousTrack"] == null ? (Math.min(...this.context["playedPages"]) > 1)
+                        : Math.max(...this.context["playedPages"]) > this.context["pagesCount"]
+            
+            let formdata = new FormData()
+            formdata.append("context", this.context["context_type"])
+            formdata.append("context_entity", this.context["context_id"])
+            formdata.append("hash", u("meta[name=csrf]").attr("value"))
+
+            if(lesser)
+                formdata.append("page", Math.min(...this.context["playedPages"]) - 1)
+            else
+                formdata.append("page", Number(Math.max(...this.context["playedPages"])) + 1)
+            
+            ky.post("/audios/context", {
+                hooks: {
+                    afterResponse: [
+                        async (_request, _options, response) => {
+                            let newArr = await response.json()
+
+                            if(lesser)
+                                this.tracks["tracks"] = newArr["items"].concat(this.tracks["tracks"])
+                            else
+                                this.tracks["tracks"] = this.tracks["tracks"].concat(newArr["items"])
+
+                            this.context["playedPages"].push(String(newArr["page"]))
+
+                            if(lesser)
+                                this.tracks["previousTrack"] = this.tracks["tracks"].at(this.tracks["tracks"].indexOf(obj) - 1).id
+                            else
+                                this.tracks["nextTrack"] = this.tracks["tracks"].at(indexOfCurrentTrack + 1) != null ? this.tracks["tracks"].at(indexOfCurrentTrack + 1).id : null
+                            
+                            this.updateButtons()
+                            console.info("Context is successfully loaded")
+                        }
+                    ]
+                }, 
+                body: formdata
+            })
+        }
+
+        if(this.tracks["currentTrack"].available == false || this.tracks["currentTrack"].withdrawn)
+            this.showNextTrack()
+
+        this.updateButtons()
+
+        const protData = {
+            "org.w3.clearkey": {
+                "clearkeys": obj.keys
+            }
+        };
+
+        this.nodes["dashPlayer"].initialize(this.player(), obj.url, false);
+        this.nodes["dashPlayer"].setProtectionData(protData);
+
+        this.play()
+
+        let playerAtPage = document.querySelector(`.audioEmbed[data-realid='${this.tracks["currentTrack"].id}'] .audioEntry`)
+        if(playerAtPage != null)
+            playerAtPage.classList.add("nowPlaying")
+
+        document.querySelectorAll(`.audioEntry .playerButton .playIcon.paused`).forEach(el => el.classList.remove("paused"))
+        
+        localStorage.lastPlayedTrack = this.tracks["currentTrack"].id
+
+        if(this.timeType == 1)
+            this.nodes["thisPlayer"].querySelector(".elapsedTime").innerHTML = fmtTime(this.tracks["currentTrack"].length)
+
+        let album = document.querySelector(".playlistBlock")
+
+        navigator.mediaSession.metadata = new MediaMetadata({
+            title: obj.name,
+            artist: obj.performer,
+            album: album == null ? "OpenVK Audios" : album.querySelector(".playlistInfo h4").innerHTML,
+            artwork: [{ src: album == null ? "/assets/packages/static/openvk/img/song.jpg" : album.querySelector(".playlistCover img").src }],
+        });
+
+        navigator.mediaSession.setPositionState({
+            duration: this.tracks["currentTrack"].length
+        })
+    }
+}
+
+document.addEventListener("DOMContentLoaded", function() {
+    if(document.querySelector(".bigPlayer") != null) {
+        let context = document.querySelector("input[name='bigplayer_context']")
+
+        if(!context)
+            return
+        
+        let type = context.dataset.type
+        let entity = context.dataset.entity
+        window.player = new bigPlayer(type, entity, context.dataset.page)
+
+        let bigplayer = document.querySelector('.bigPlayerDetector')
+
+        let bigPlayerObserver = new IntersectionObserver(entries => {
+            entries.forEach(x => {
+                if(x.isIntersecting) {
+                    document.querySelector('.bigPlayer').classList.remove("floating")
+                    document.querySelector('.searchOptions .searchList').classList.remove("floating")
+                    document.querySelector('.bigPlayerDetector').style.marginTop = "0px"
+                } else {
+                    document.querySelector('.searchOptions .searchList').classList.add("floating")
+                    document.querySelector('.bigPlayer').classList.add("floating")
+                    document.querySelector('.bigPlayerDetector').style.marginTop = "46px"
+                }
+            });
+        }, {
+            root: null,
+            rootMargin: "0px",
+            threshold: 0
+        });
+
+        if(bigplayer != null)
+            bigPlayerObserver.observe(bigplayer);
+    }
+
+    $(`.audioEntry .mediaInfo`).on("mouseover mouseleave", (e) => {
+        const info = e.currentTarget.closest(".mediaInfo")
+        const overfl = info.querySelector(".info")
+
+        if(e.originalEvent.type == "mouseleave" || e.originalEvent.type == "mouseout") {
+            info.classList.add("noOverflow")
+            info.classList.remove("overflowedName")
+        } else {
+            info.classList.remove("noOverflow")
+            info.classList.add("overflowedName")
+        }
+    })
+})
+
+$(document).on("click", ".audioEmbed > *", (e) => {
+    const player = e.currentTarget.closest(".audioEmbed")
+
+    if(player.classList.contains("inited")) return
+
+    initPlayer(player.id.replace("audioEmbed-", ""), 
+    JSON.parse(player.dataset.keys), 
+    player.dataset.url, 
+    player.dataset.length)
+
+    if(e.target.classList.contains("playIcon"))
+        e.target.click()
+})
+
+function initPlayer(id, keys, url, length) {
+    document.querySelector(`#audioEmbed-${ id}`).classList.add("inited")
+    const audio = document.querySelector(`#audioEmbed-${ id} .audio`);
+    const playButton = u(`#audioEmbed-${ id} .playerButton > .playIcon`);
+    const trackDiv = u(`#audioEmbed-${ id} .track > div > div`);
+    const volumeSpan = u(`#audioEmbed-${ id} .volume span`);
+    const rect = document.querySelector(`#audioEmbed-${ id} .selectableTrack`).getBoundingClientRect();
+    
+    const playerObject = document.querySelector(`#audioEmbed-${ id}`)
+
+    if(document.querySelector(".bigPlayer") != null) {
+        playButton.on("click", () => {
+            if(window.player.tracks["tracks"] == null)
+                return
+
+            if(window.player.tracks["currentTrack"] == null || window.player.tracks["currentTrack"].id != playerObject.dataset.realid)
+                window.player.setTrack(playerObject.dataset.realid)
+            else
+                document.querySelector(".bigPlayer .playButton").click()
+        })
+
+        return
+    }
+    
+    const protData = {
+        "org.w3.clearkey": {
+            "clearkeys": keys
+        }
+    };
+
+    const player = dashjs.MediaPlayer().create();
+    player.initialize(audio, url, false);
+    player.setProtectionData(protData);
+
+    playButton.on("click", () => {
+        if (audio.paused) {
+            document.querySelectorAll('audio').forEach(el => el.pause());
+            audio.play();
+        } else {
+            audio.pause();
+        }
+    });
+
+    u(audio).on("timeupdate", () => {
+        const time = audio.currentTime;
+        const ps = ((time * 100) / length).toFixed(3);
+        volumeSpan.html(fmtTime(Math.floor(time)));
+
+        if (ps <= 100)
+            playerObject.querySelector(".lengthTrack .slider").style.left = `${ ps}%`;
+    });
+
+    u(audio).on("volumechange", (e) => {
+        const volume = audio.volume;
+        const ps = Math.ceil((volume * 100) / 1);
+
+        if (ps <= 100)
+            playerObject.querySelector(".volumeTrack .slider").style.left = `${ ps}%`;
+    })
+
+    const playButtonImageUpdate = () => {
+        if (!audio.paused) {
+            playButton.addClass("paused")
+            document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_paused.png")
+        } else {
+            playButton.removeClass("paused")
+            document.querySelector('link[rel="icon"], link[rel="shortcut icon"]').setAttribute("href", "/assets/packages/static/openvk/img/favicons/favicon24_playing.png")
+        }
+        
+        if(!$(`#audioEmbed-${ id}`).hasClass("havePlayed")) {
+            $(`#audioEmbed-${ id}`).addClass("havePlayed")
+
+            $(`#audioEmbed-${ id} .track`).toggle()
+
+            $.post(`/audio${playerObject.dataset.realid}/listen`, {
+                hash: u("meta[name=csrf]").attr("value")
+            });
+        }
+    };
+
+    const hideTracks = () => {
+        $(`#audioEmbed-${ id} .track`).toggle()
+        $(`#audioEmbed-${ id}`).removeClass("havePlayed")
+    }
+
+    u(audio).on("play", playButtonImageUpdate);
+    u(audio).on(["pause", "suspended"], playButtonImageUpdate);
+    u(audio).on("ended", (e) => {
+        let thisPlayer = e.target.closest(".audioEmbed")
+        let nextPlayer = null
+        if(thisPlayer.closest(".attachment") != null) {
+            try {
+                nextPlayer = thisPlayer.closest(".attachment").nextElementSibling.querySelector(".audioEmbed")
+            } catch(e) {return}
+        } else if(thisPlayer.closest(".audio") != null) {
+            try {
+                nextPlayer = thisPlayer.closest(".audio").nextElementSibling.querySelector(".audioEmbed")
+            } catch(e) {return}
+        } else {
+            nextPlayer = thisPlayer.nextElementSibling
+        }
+
+        playButtonImageUpdate()
+
+        if(!nextPlayer) return
+
+        initPlayer(nextPlayer.id.replace("audioEmbed-", ""), 
+            JSON.parse(nextPlayer.dataset.keys), 
+            nextPlayer.dataset.url, 
+            nextPlayer.dataset.length)
+        
+        nextPlayer.querySelector(".playIcon").click()
+        hideTracks()
+    })
+
+    u(`#audioEmbed-${ id} .lengthTrack > div`).on("click", (e) => {
+        let rect  = document.querySelector("#audioEmbed-" + id + " .selectableTrack").getBoundingClientRect();
+        const width = e.clientX - rect.left;
+        const time = Math.ceil((width * length) / (rect.right - rect.left));
+
+        audio.currentTime = time;
+    });
+
+    u(`#audioEmbed-${ id} .volumeTrack > div`).on("click mouseup mousemove", (e) => {
+        if(e.type == "mousemove") {
+            let buttonsPresseed = _bsdnUnwrapBitMask(e.buttons)
+            if(!buttonsPresseed[0])
+                return;
+        }
+
+        let rect = document.querySelector("#audioEmbed-" + id + " .volumeTrack").getBoundingClientRect();
+        
+        const width = e.clientX - rect.left;
+        const volume = (width * 1) / (rect.right - rect.left);
+
+        audio.volume = volume;
+    });
+
+    audio.volume = localStorage.volume ?? 0.75
+    u(audio).trigger("volumechange")
+}
+
+$(document).on("click", ".musicIcon.edit-icon", (e) => {
+    let player = e.currentTarget.closest(".audioEmbed")
+    let id = Number(player.dataset.realid)
+    let performer = e.currentTarget.dataset.performer
+    let name = e.currentTarget.dataset.title
+    let genre = player.dataset.genre
+    let lyrics = e.currentTarget.dataset.lyrics
+    
+    MessageBox(tr("edit_audio"), `
+        <div>
+            ${tr("performer")}
+            <input name="performer" maxlength="256" type="text" value="${performer}">
+        </div>
+
+        <div style="margin-top: 11px">
+            ${tr("audio_name")}
+            <input name="name" maxlength="256" type="text" value="${name}">
+        </div>
+
+        <div style="margin-top: 11px">
+            ${tr("genre")}
+            <select name="genre"></select>
+        </div>
+
+        <div style="margin-top: 11px">
+            ${tr("lyrics")}
+            <textarea name="lyrics" maxlength="5000" style="max-height: 200px;">${lyrics ?? ""}</textarea>
+        </div>
+
+        <div style="margin-top: 11px">
+            <label><input type="checkbox" name="explicit" ${e.currentTarget.dataset.explicit == 1 ? "checked" : ""}>${tr("audios_explicit")}</label><br>
+            <label><input type="checkbox" name="searchable" ${e.currentTarget.dataset.searchable == 1 ? "checked" : ""}>${tr("searchable")}</label>
+            <hr>
+            <a id="_fullyDeleteAudio">${tr("fully_delete_audio")}</a>
+        </div>
+    `, [tr("save"), tr("cancel")], [
+        function() {
+            let t_name   = $(".ovk-diag-body input[name=name]").val();
+            let t_perf   = $(".ovk-diag-body input[name=performer]").val();
+            let t_genre  = $(".ovk-diag-body select[name=genre]").val();
+            let t_lyrics = $(".ovk-diag-body textarea[name=lyrics]").val();
+            let t_explicit = document.querySelector(".ovk-diag-body input[name=explicit]").checked;
+            let t_unlisted = document.querySelector(".ovk-diag-body input[name=searchable]").checked;
+
+            $.ajax({
+                type: "POST",
+                url: `/audio${id}/action?act=edit`,
+                data: {
+                    name: t_name,
+                    performer: t_perf,
+                    genre: t_genre,
+                    lyrics: t_lyrics,
+                    unlisted: Number(t_unlisted),
+                    explicit: Number(t_explicit),
+                    hash: u("meta[name=csrf]").attr("value")
+                },
+                success: (response) => {
+                    if(response.success) {
+                        let perf = player.querySelector(".performer a")
+                        perf.innerHTML = escapeHtml(response.new_info.performer)
+                        perf.setAttribute("href", "/search?query=&type=audios&sort=id&only_performers=on&query="+response.new_info.performer)
+                        
+                        e.target.setAttribute("data-performer", escapeHtml(response.new_info.performer))
+                        
+                        let name = player.querySelector(".title")
+                        name.innerHTML = escapeHtml(response.new_info.name)
+
+                        e.target.setAttribute("data-title", escapeHtml(response.new_info.name))
+                        
+                        if(response.new_info.lyrics_unformatted != "") {
+                            if(player.querySelector(".lyrics") != null) {
+                                player.querySelector(".lyrics").innerHTML = response.new_info.lyrics
+                                player.querySelector(".title").classList.add("withLyrics")
+                            } else {
+                                player.insertAdjacentHTML("beforeend", `
+                                    <div class="lyrics">
+                                        ${response.new_info.lyrics}
+                                    </div>
+                                `)
+    
+                                player.querySelector(".title").classList.add("withLyrics")
+                            }
+                        } else {
+                            $(player.querySelector(".lyrics")).remove()
+                            player.querySelector(".title").classList.remove("withLyrics")
+                        }
+
+                        e.target.setAttribute("data-lyrics", response.new_info.lyrics_unformatted)
+                        e.target.setAttribute("data-explicit", Number(response.new_info.explicit))
+
+                        if(Number(response.new_info.explicit) == 1) {
+                            if(!player.querySelector(".mediaInfo .explicitMark"))
+                                player.querySelector(".mediaInfo").insertAdjacentHTML("beforeend", `
+                                    <div class="explicitMark"></div>
+                                `)
+                        } else {
+                            $(player.querySelector(".mediaInfo .explicitMark")).remove()
+                        }
+
+                        e.target.setAttribute("data-searchable", Number(!response.new_info.unlisted))
+                        player.setAttribute("data-genre", response.new_info.genre)
+
+                        let url = new URL(location.href)
+                        let page = "1"
+
+                        if(url.searchParams.p != null)
+                            page = String(url.searchParams.p)
+                        
+                        window.savedAudiosPages[page] = null
+                    } else
+                        fastError(response.flash.message)
+                }
+            });
+        },
+
+        Function.noop
+    ]);
+
+    window.openvk.audio_genres.forEach(elGenre => {
+        document.querySelector(".ovk-diag-body select[name=genre]").insertAdjacentHTML("beforeend", `
+            <option value="${elGenre}" ${elGenre == genre ? "selected" : ""}>${elGenre}</option>
+        `)
+    })
+
+    u(".ovk-diag-body #_fullyDeleteAudio").on("click", (e) => {
+        u("body").removeClass("dimmed");
+        document.querySelector("html").style.overflowY = "scroll"
+
+        u(".ovk-diag-cont").remove();
+
+        $.ajax({
+            type: "POST",
+            url: `/audio${id}/action?act=delete`,
+            data: {
+                hash: u("meta[name=csrf]").attr("value")
+            },
+            success: (response) => {
+                if(response.success)
+                    u(player).remove()
+                else
+                    fastError(response.flash.message)
+            }
+        });
+    })
+})
+
+$(document).on("click", ".title.withLyrics", (e) => {
+    let parent = e.currentTarget.closest(".audioEmbed")
+
+    parent.querySelector(".lyrics").classList.toggle("showed")
+})
+
+$(document).on("click", ".musicIcon.remove-icon", (e) => {
+    e.stopImmediatePropagation()
+
+    let id = e.currentTarget.dataset.id
+
+    let formdata = new FormData()
+    formdata.append("hash", u("meta[name=csrf]").attr("value"))
+
+    ky.post(`/audio${id}/action?act=remove`, {
+        hooks: {
+            beforeRequest: [
+                (_request) => {
+                    e.target.classList.add("lagged")
+                }
+            ],
+            afterResponse: [
+                async (_request, _options, response) => {
+                    let json = await response.json()
+
+                    if(json.success) {
+                        e.target.classList.remove("remove-icon")
+                        e.target.classList.add("add-icon")
+                        e.target.classList.remove("lagged")
+
+                        let withd = e.target.closest(".audioEmbed.withdrawn")
+
+                        if(withd != null)
+                            u(withd).remove()
+                    } else
+                        fastError(json.flash.message)
+                }
+            ]
+        }, body: formdata
+    })
+})
+
+$(document).on("click", ".musicIcon.remove-icon-group", (e) => {
+    e.stopImmediatePropagation()
+    
+    let id = e.currentTarget.dataset.id
+
+    let formdata = new FormData()
+    formdata.append("hash", u("meta[name=csrf]").attr("value"))
+    formdata.append("club", e.currentTarget.dataset.club)
+
+    ky.post(`/audio${id}/action?act=remove_club`, {
+        hooks: {
+            beforeRequest: [
+                (_request) => {
+                    e.currentTarget.classList.add("lagged")
+                }
+            ],
+            afterResponse: [
+                async (_request, _options, response) => {
+                    let json = await response.json()
+
+                    if(json.success)
+                        $(e.currentTarget.closest(".audioEmbed")).remove()
+                    else
+                        fastError(json.flash.message)
+                }
+            ]
+        }, body: formdata
+    })
+})
+
+$(document).on("click", ".musicIcon.add-icon-group", async (ev) => {
+    let body = `
+        ${tr("what_club_add")}
+        <div style="margin-top: 4px;">
+            <select id="addIconsWindow" style="width: 59%;"></select>
+            <input name="addButton" type="button" class="button" value="${tr("add")}">
+        </div>
+        <span class="errorPlace"></span>
+    `
+    MessageBox(tr("add_audio_to_club"), body, [tr("close")], [Function.noop])
+
+    document.querySelector(".ovk-diag-body").style.padding = "11px"
+
+    if(window.openvk.writeableClubs == null) {
+        try {
+            window.openvk.writeableClubs = await API.Groups.getWriteableClubs()
+        } catch (e) {
+            document.querySelector(".errorPlace").innerHTML = tr("no_access_clubs")
+            document.querySelector(".ovk-diag-body input[name='addButton']").classList.add("lagged")
+
+            return
+        }
+    }
+
+    window.openvk.writeableClubs.forEach(el => {
+        document.querySelector("#addIconsWindow").insertAdjacentHTML("beforeend", `
+            <option value="${el.id}">${ovk_proc_strtr(el.name, 20)}</option>
+        `)
+    })
+
+    $(".ovk-diag-body").on("click", "input[name='addButton']", (e) => {
+        $.ajax({
+            type: "POST",
+            url: `/audio${ev.target.dataset.id}/action?act=add_to_club`,
+            data: {
+                hash: u("meta[name=csrf]").attr("value"),
+                club: document.querySelector("#addIconsWindow").value
+            },
+            beforeSend: () => {
+                e.target.classList.add("lagged")
+                document.querySelector(".errorPlace").innerHTML = ""
+            },
+            success: (response) => {
+                if(!response.success)
+                    document.querySelector(".errorPlace").innerHTML = response.flash.message
+
+                e.currentTarget.classList.remove("lagged")
+            }
+        })
+    })
+})
+
+$(document).on("click", ".musicIcon.add-icon", (e) => {
+    let id = e.currentTarget.dataset.id
+
+    let formdata = new FormData()
+    formdata.append("hash", u("meta[name=csrf]").attr("value"))
+
+    ky.post(`/audio${id}/action?act=add`, {
+        hooks: {
+            beforeRequest: [
+                (_request) => {
+                    e.target.classList.add("lagged")
+                }
+            ],
+            afterResponse: [
+                async (_request, _options, response) => {
+                    let json = await response.json()
+
+                    if(json.success) {
+                        e.target.classList.remove("add-icon")
+                        e.target.classList.add("remove-icon")
+                        e.target.classList.remove("lagged")
+                    } else
+                       fastError(json.flash.message)
+                }
+            ]
+        }, body: formdata
+    })
+})
+
+$(document).on("click", "#_deletePlaylist", (e) => {
+    let id = e.currentTarget.dataset.id
+
+    MessageBox(tr("warning"), tr("sure_delete_playlist"), [tr("yes"), tr("no")], [() => {
+        $.ajax({
+            type: "POST",
+            url: `/playlist${id}/action?act=delete`,
+            data: {
+                hash: u("meta[name=csrf]").attr("value"),
+            },
+            beforeSend: () => {
+                e.currentTarget.classList.add("lagged")
+            },
+            success: (response) => {
+                if(response.success) {
+                    window.location.assign("/playlists" + response.id)
+                } else {
+                    fastError(response.flash.message)
+                }
+            }
+        })
+    }, Function.noop])
+})
+
+$(document).on("click", "#_audioAttachment", (e) => {
+    let form = e.currentTarget.closest("form")
+    let body = `
+        <div class="searchBox">
+            <input name="query" type="text" maxlength="50" placeholder="${tr("header_search")}">
+            <select name="perf">
+                <option value="by_name">${tr("by_name")}</option>
+                <option value="by_performer">${tr("by_performer")}</option>
+            </select>
+        </div>
+
+        <div class="audiosInsert"></div>
+    `
+    MessageBox(tr("select_audio"), body, [tr("ok")], [Function.noop])
+
+    document.querySelector(".ovk-diag-body").style.padding = "0"
+    document.querySelector(".ovk-diag-cont").style.width = "580px"
+    document.querySelector(".ovk-diag-body").style.height = "335px"
+
+    let searcher = new playersSearcher("entity_audios", 0)
+    searcher.successCallback = (response, thisc) => {
+        let domparser = new DOMParser()
+        let result = domparser.parseFromString(response, "text/html")
+
+        let pagesCount = result.querySelector("input[name='pagesCount']").value
+        let count = Number(result.querySelector("input[name='count']").value)
+
+        if(count < 1) {
+            document.querySelector(".audiosInsert").innerHTML = thisc.context_type == "entity_audios" ? tr("no_audios_thisuser") : tr("no_results")
+            return
+        }
+
+        result.querySelectorAll(".audioEmbed").forEach(el => {
+            let id = el.dataset.prettyid
+            let name = el.dataset.name
+            let isAttached = (form.querySelector("input[name='audios']").value.includes(`${id},`))
+            document.querySelector(".audiosInsert").insertAdjacentHTML("beforeend", `
+                <div style="display: table;width: 100%;clear: both;">
+                    <div style="width: 72%;float: left;">${el.outerHTML}</div>
+                    <div class="attachAudio" data-attachmentdata="${id}" data-name="${name}">
+                        <span>${isAttached ? tr("detach_audio") : tr("attach_audio")}</span>
+                    </div>
+                </div>
+            `)
+        })
+
+        u("#loader").remove()
+
+        if(thisc.page < pagesCount) {
+            document.querySelector(".audiosInsert").insertAdjacentHTML("beforeend", `
+            <div id="showMoreAudios" data-pagesCount="${pagesCount}" data-page="${thisc.page + 1}" class="showMore">
+                <span>${tr("show_more_audios")}</span>
+            </div>`)
+        }
+    }
+
+    searcher.errorCallback = () => {
+        fastError("Error when loading players.")
+    }
+
+    searcher.beforesendCallback = () => {
+        document.querySelector(".audiosInsert").insertAdjacentHTML("beforeend", `<img id="loader" src="/assets/packages/static/openvk/img/loading_mini.gif">`)
+    }
+
+    searcher.clearContainer = () => {
+        document.querySelector(".audiosInsert").innerHTML = ""
+    }
+
+    searcher.movePage(1)
+
+    $(".audiosInsert").on("click", "#showMoreAudios", (e) => {
+        u(e.currentTarget).remove()
+        searcher.movePage(Number(e.currentTarget.dataset.page))
+    })
+
+    $(".searchBox input").on("change", async (e) => {
+        await new Promise(r => setTimeout(r, 500));
+
+        if(e.currentTarget.value === document.querySelector(".searchBox input").value) {
+            searcher.clearContainer()
+
+            if(e.currentTarget.value == "") {
+                searcher.context_type = "entity_audios"
+                searcher.context_id = 0
+                searcher.query = ""
+
+                searcher.movePage(1)
+
+                return
+            }
+
+            searcher.context_type = "search_context"
+            searcher.context_id = 0
+            searcher.query = e.currentTarget.value
+
+            searcher.movePage(1)
+            return;
+        }
+    })
+
+    $(".searchBox select").on("change", async (e) => {
+        searcher.clearContainer()
+        searcher.searchType = e.currentTarget.value
+
+        $(".searchBox input").trigger("change")
+        return;
+    })
+
+    function insertAttachment(id) {
+        let audios = form.querySelector("input[name='audios']") 
+
+        if(!audios.value.includes(id + ",")) {
+            if(audios.value.split(",").length > 10) {
+                NewNotification(tr("error"), tr("max_attached_audios"))
+                return false
+            }
+
+            form.querySelector("input[name='audios']").value += (id + ",")
+
+            return true
+        } else {
+            form.querySelector("input[name='audios']").value = form.querySelector("input[name='audios']").value.replace(id + ",", "")
+
+            return false
+        }
+    }
+
+    $(".audiosInsert").on("click", ".attachAudio", (ev) => {
+        if(!insertAttachment(ev.currentTarget.dataset.attachmentdata)) {
+            u(`.post-has-audios .post-has-audio[data-id='${ev.currentTarget.dataset.attachmentdata}']`).remove()
+            ev.currentTarget.querySelector("span").innerHTML = tr("attach_audio")
+        } else {
+            ev.currentTarget.querySelector("span").innerHTML = tr("detach_audio")
+
+            form.querySelector(".post-has-audios").insertAdjacentHTML("beforeend", `
+                <div class="post-has-audio" id="unattachAudio" data-id="${ev.currentTarget.dataset.attachmentdata}">
+                    <span>${ovk_proc_strtr(escapeHtml(ev.currentTarget.dataset.name), 40)}</span>
+                </div>
+            `)
+
+            u(`#unattachAudio[data-id='${ev.currentTarget.dataset.attachmentdata}']`).on("click", (e) => {
+                let id = ev.currentTarget.dataset.attachmentdata
+                form.querySelector("input[name='audios']").value = form.querySelector("input[name='audios']").value.replace(id + ",", "")
+
+                u(e.currentTarget).remove()
+            })
+        }
+    })
+})
+
+$(document).on("click", ".audioEmbed.processed", (e) => {
+    MessageBox(tr("error"), tr("audio_embed_processing"), [tr("ok")], [Function.noop])
+})
+
+$(document).on("click", ".audioEmbed.withdrawn", (e) => {
+    MessageBox(tr("error"), tr("audio_embed_withdrawn"), [tr("ok")], [Function.noop])
+})
+
+$(document).on("click", ".musicIcon.report-icon", (e) => {
+    MessageBox(tr("report_question"), `
+        ${tr("going_to_report_audio")}
+        <br/>${tr("report_question_text")}
+        <br/><br/><b> ${tr("report_reason")}</b>: <input type='text' id='uReportMsgInput' placeholder='${tr("reason")}' />`, [tr("confirm_m"), tr("cancel")], [(function() {
+        
+        res = document.querySelector("#uReportMsgInput").value;
+        xhr = new XMLHttpRequest();
+        xhr.open("GET", "/report/" + e.target.dataset.id + "?reason=" + res + "&type=audio", true);
+        xhr.onload = (function() {
+        if(xhr.responseText.indexOf("reason") === -1)
+            MessageBox(tr("error"), tr("error_sending_report"), ["OK"], [Function.noop]);
+        else
+           MessageBox(tr("action_successfully"), tr("will_be_watched"), ["OK"], [Function.noop]);
+        });
+        xhr.send(null)
+    }),
+
+    Function.noop])
+})
+
+$(document).on("click", ".audiosContainer .paginator a", (e) => {
+    e.preventDefault()
+    let url = new URL(e.currentTarget.href)
+    let page = url.searchParams.get("p")
+
+    function searchNode(id) {
+        let node = document.querySelector(`.audioEmbed[data-realid='${id}'] .audioEntry`)
+
+        if(node != null) {
+            node.classList.add("nowPlaying")
+        }
+    }
+
+    if(window.savedAudiosPages[page] != null) {
+        history.pushState({}, "", e.currentTarget.href)
+        document.querySelector(".audiosContainer").innerHTML = window.savedAudiosPages[page].innerHTML
+        searchNode(window.player["tracks"].currentTrack != null ? window.player["tracks"].currentTrack.id : 0)
+
+        return
+    }
+
+    e.currentTarget.parentNode.classList.add("lagged")
+    $.ajax({
+        type: "GET",
+        url: e.currentTarget.href,
+        success: (response) => {
+            let domparser = new DOMParser()
+            let result = domparser.parseFromString(response, "text/html")
+
+            document.querySelector(".audiosContainer").innerHTML = result.querySelector(".audiosContainer").innerHTML
+            history.pushState({}, "", e.currentTarget.href)
+            window.savedAudiosPages[page] = result.querySelector(".audiosContainer")
+            searchNode(window.player["tracks"].currentTrack != null ? window.player["tracks"].currentTrack.id : 0)
+
+            if(!window.player.context["playedPages"].includes(page)) {
+                $.ajax({
+                    type: "POST",
+                    url: "/audios/context",
+                    data: {
+                        context: window.player["context"].context_type,
+                        context_entity: window.player["context"].context_id,
+                        hash: u("meta[name=csrf]").attr("value"),
+                        page: page
+                    },
+                    success: (response_2) => {
+                        window.player.tracks["tracks"] = window.player.tracks["tracks"].concat(response_2["items"])
+                        window.player.context["playedPages"].push(String(page))
+                        console.info("Page is switched")
+                    }
+                })
+            }
+        }
+    })
+})
+
+$(document).on("click", ".addToPlaylist", (e) => {
+    let audios = document.querySelector("input[name='audios']") 
+    let id = e.currentTarget.dataset.id
+
+    if(!audios.value.includes(id + ",")) {
+        document.querySelector("input[name='audios']").value += (id + ",")
+        e.currentTarget.querySelector("span").innerHTML = tr("remove_from_playlist")
+    } else {
+        document.querySelector("input[name='audios']").value = document.querySelector("input[name='audios']").value.replace(id + ",", "")
+        e.currentTarget.querySelector("span").innerHTML = tr("add_to_playlist")
+    }
+})
+
+$(document).on("click", "#bookmarkPlaylist, #unbookmarkPlaylist", (e) => {
+    let target = e.currentTarget
+    let id = target.id
+
+    $.ajax({
+        type: "POST",
+        url: `/playlist${e.currentTarget.dataset.id}/action?act=${id == "unbookmarkPlaylist" ? "unbookmark" : "bookmark"}`,
+        data: {
+            hash: u("meta[name=csrf]").attr("value"),
+        },
+        beforeSend: () => {
+            e.currentTarget.classList.add("lagged")
+        },
+        success: (response) => {
+            if(response.success) {
+                e.currentTarget.setAttribute("id", id == "unbookmarkPlaylist" ? "bookmarkPlaylist" : "unbookmarkPlaylist")
+                e.currentTarget.innerHTML = id == "unbookmarkPlaylist" ? tr("bookmark") : tr("unbookmark")
+                e.currentTarget.classList.remove("lagged")
+            } else
+                fastError(response.flash.message)
+        }
+    })
+})
diff --git a/Web/static/js/al_playlists.js b/Web/static/js/al_playlists.js
new file mode 100644
index 00000000..cf61c45d
--- /dev/null
+++ b/Web/static/js/al_playlists.js
@@ -0,0 +1,113 @@
+let context_type = "entity_audios"
+let context_id = 0
+
+if(document.querySelector("#editPlaylistForm")) {
+    context_type = "playlist_context"
+    context_id = document.querySelector("#editPlaylistForm").dataset.id
+}
+
+if(document.querySelector(".showMoreAudiosPlaylist") && document.querySelector(".showMoreAudiosPlaylist").dataset.club != null) {
+    context_type = "entity_audios"
+    context_id = Number(document.querySelector(".showMoreAudiosPlaylist").dataset.club) * -1
+}
+
+let searcher = new playersSearcher(context_type, context_id)
+
+searcher.successCallback = (response, thisc) => {
+    let domparser = new DOMParser()
+    let result = domparser.parseFromString(response, "text/html")
+    let pagesCount = Number(result.querySelector("input[name='pagesCount']").value)
+    let count = Number(result.querySelector("input[name='count']").value)
+
+    result.querySelectorAll(".audioEmbed").forEach(el => {
+        let id = Number(el.dataset.realid)
+        let isAttached = (document.querySelector("input[name='audios']").value.includes(`${id},`))
+
+        document.querySelector(".playlistAudiosContainer").insertAdjacentHTML("beforeend", `
+            <div id="newPlaylistAudios">
+                <div class="playerContainer">
+                    ${el.outerHTML}
+                </div>
+                <div class="attachAudio addToPlaylist" data-id="${id}">
+                    <span>${isAttached ? tr("remove_from_playlist") : tr("add_to_playlist")}</span>
+                </div>
+            </div>
+        `)
+    })
+
+    if(count < 1)
+        document.querySelector(".playlistAudiosContainer").insertAdjacentHTML("beforeend", `
+            ${tr("no_results")}
+        `)
+
+    if(Number(thisc.page) >= pagesCount)
+        u(".showMoreAudiosPlaylist").remove()
+    else {
+        if(document.querySelector(".showMoreAudiosPlaylist") != null) {
+            document.querySelector(".showMoreAudiosPlaylist").setAttribute("data-page", thisc.page + 1)
+
+            if(thisc.query != "") {
+                document.querySelector(".showMoreAudiosPlaylist").setAttribute("data-query", thisc.query)
+            }
+
+            document.querySelector(".showMoreAudiosPlaylist").style.display = "block"
+        } else {
+            document.querySelector(".playlistAudiosContainer").parentNode.insertAdjacentHTML("beforeend", `
+                <div class="showMoreAudiosPlaylist" data-page="2" 
+                ${thisc.query != "" ? `"data-query="${thisc.query}"` : ""} 
+                ${thisc.context_type == "entity_audios" ? `"data-playlist="${thisc.context_id}"` : ""}
+                ${thisc.context_id < 0 ? `"data-club="${thisc.context_id}"` : ""}>
+                    ${tr("show_more_audios")}
+                </div>
+            `)
+        }
+    }
+
+    u("#loader").remove()
+}
+
+searcher.beforesendCallback = () => {
+    document.querySelector(".playlistAudiosContainer").parentNode.insertAdjacentHTML("beforeend", `<img id="loader" src="/assets/packages/static/openvk/img/loading_mini.gif">`)
+
+    if(document.querySelector(".showMoreAudiosPlaylist") != null)
+        document.querySelector(".showMoreAudiosPlaylist").style.display = "none"
+}
+
+searcher.errorCallback = () => {
+    fastError("Error when loading players")
+}
+
+searcher.clearContainer = () => {
+    document.querySelector(".playlistAudiosContainer").innerHTML = ""
+}
+
+$(document).on("click", ".showMoreAudiosPlaylist", (e) => {
+    searcher.movePage(Number(e.currentTarget.dataset.page))
+})
+
+$(document).on("change", "input#playlist_query", async (e) => {
+    e.preventDefault()
+    
+    await new Promise(r => setTimeout(r, 500));
+
+    if(e.currentTarget.value === document.querySelector("input#playlist_query").value) {
+        searcher.clearContainer()
+
+        if(e.currentTarget.value == "") {
+            searcher.context_type = "entity_audios"
+            searcher.context_id = 0
+            searcher.query = ""
+
+            searcher.movePage(1)
+
+            return
+        }
+
+        searcher.context_type = "search_context"
+        searcher.context_id = 0
+        searcher.query = e.currentTarget.value
+
+        searcher.movePage(1)
+        return;
+    }
+})
diff --git a/Web/static/js/al_wall.js b/Web/static/js/al_wall.js
index 39ee2199..5acba57f 100644
--- a/Web/static/js/al_wall.js
+++ b/Web/static/js/al_wall.js
@@ -155,18 +155,6 @@ function setupWallPostInputHandlers(id) {
             return;
         }
     });
-    
-    u("#wall-post-input" + id).on("input", function(e) {
-        var boost             = 5;
-        var textArea          = e.target;
-        textArea.style.height = "5px";
-        var newHeight = textArea.scrollHeight;
-        textArea.style.height = newHeight + boost + "px";
-        return;
-        
-        // revert to original size if it is larger (possibly changed by user)
-        // textArea.style.height = (newHeight > originalHeight ? (newHeight + boost) : originalHeight) + "px";
-    });
 
     u("#wall-post-input" + id).on("dragover", function(e) {
         e.preventDefault()
@@ -182,6 +170,18 @@ function setupWallPostInputHandlers(id) {
     });
 }
 
+u(document).on("input", "textarea", function(e) {
+    var boost             = 5;
+    var textArea          = e.target;
+    textArea.style.height = "5px";
+    var newHeight = textArea.scrollHeight;
+    textArea.style.height = newHeight + boost + "px";
+    return;
+    
+    // revert to original size if it is larger (possibly changed by user)
+    // textArea.style.height = (newHeight > originalHeight ? (newHeight + boost) : originalHeight) + "px";
+});
+
 function OpenMiniature(e, photo, post, photo_id, type = "post") {
     /*
     костыли но смешные однако
diff --git a/Web/static/js/package.json b/Web/static/js/package.json
index f9459ce4..f5c37008 100644
--- a/Web/static/js/package.json
+++ b/Web/static/js/package.json
@@ -2,6 +2,8 @@
   "dependencies": {
     "@atlassian/aui": "^9.6.0",
     "create-react-class": "^15.7.0",
+    "dashjs": "^4.3.0",
+    "id3js": "^2.1.1",
     "handlebars": "^4.7.7",
     "jquery": "^3.0.0",
     "knockout": "^3.5.1",
diff --git a/Web/static/js/yarn.lock b/Web/static/js/yarn.lock
index fa61ed9b..289fc81e 100644
--- a/Web/static/js/yarn.lock
+++ b/Web/static/js/yarn.lock
@@ -41,6 +41,11 @@ backbone@1.4.1:
   dependencies:
     underscore ">=1.8.3"
 
+codem-isoboxer@0.3.6:
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/codem-isoboxer/-/codem-isoboxer-0.3.6.tgz#867f670459b881d44f39168d5ff2a8f14c16151d"
+  integrity sha512-LuO8/7LW6XuR5ERn1yavXAfodGRhuY2yP60JTZIw5yNYMCE5lUVbk3NFUCJxjnphQH+Xemp5hOGb1LgUXm00Xw==
+
 core-js@^1.0.0:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
@@ -64,6 +69,18 @@ dompurify@2.4.5:
   resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.5.tgz#0e89a27601f0bad978f9a924e7a05d5d2cccdd87"
   integrity sha512-jggCCd+8Iqp4Tsz0nIvpcb22InKEBrGz5dw3EQJMs8HPJDsKbFIO3STYtAvCfDx26Muevn1MHVI0XxjgFfmiSA==
 
+dashjs@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/dashjs/-/dashjs-4.3.0.tgz#cccda5a490cabf6c3b48aa887ec8c8ac0df1a233"
+  integrity sha512-cqpnJaPQpEY4DsEdF9prwD00+5dp5EGHCFc7yo9n2uuAH9k4zPkZJwXQ8dXmVRhPf3M89JfKSoAYIP3dbXmqcg==
+  dependencies:
+    codem-isoboxer "0.3.6"
+    es6-promise "^4.2.8"
+    fast-deep-equal "2.0.1"
+    html-entities "^1.2.1"
+    imsc "^1.0.2"
+    localforage "^1.7.1"
+
 encoding@^0.1.11:
   version "0.1.13"
   resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9"
@@ -71,6 +88,11 @@ encoding@^0.1.11:
   dependencies:
     iconv-lite "^0.6.2"
 
+es6-promise@^4.2.8:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
 event-lite@^0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/event-lite/-/event-lite-0.1.2.tgz#838a3e0fdddef8cc90f128006c8e55a4e4e4c11b"
@@ -81,6 +103,11 @@ fancy-file-input@2.0.4:
   resolved "https://registry.yarnpkg.com/fancy-file-input/-/fancy-file-input-2.0.4.tgz#698c216482e07649a827681c4db3054fddc9a32b"
   integrity sha512-l+J0WwDl4nM/zMJ/C8qleYnXMUJKsLng7c5uWH/miAiHoTvPDtEoLW1tmVO6Cy2O8i/1VfA+2YOwg/Q3+kgO6w==
 
+fast-deep-equal@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
+  integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
+
 fbjs@^0.8.0:
   version "0.8.17"
   resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd"
@@ -94,6 +121,10 @@ fbjs@^0.8.0:
     setimmediate "^1.0.5"
     ua-parser-js "^0.7.18"
 
+html-entities@^1.2.1:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.4.0.tgz#cfbd1b01d2afaf9adca1b10ae7dffab98c71d2dc"
+  integrity sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==
 handlebars@^4.7.7:
   version "4.7.7"
   resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1"
@@ -113,11 +144,28 @@ iconv-lite@^0.6.2:
   dependencies:
     safer-buffer ">= 2.1.2 < 3.0.0"
 
+id3js@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/id3js/-/id3js-2.1.1.tgz#0c307d0d2f194bc5fa7a809bbed0b1a93577f16d"
+  integrity sha512-9Gi+sG0RHSa5qn8hkwi2KCl+2jV8YrtiZidXbOO3uLfRAxc2jilRg0fiQ3CbeoAmR7G7ap3RVs1kqUVhIyZaog==
+
 ieee754@^1.1.8:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
+immediate@~3.0.5:
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
+  integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
+
+imsc@^1.0.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/imsc/-/imsc-1.1.3.tgz#e96a60a50d4000dd7b44097272768b9fd6a4891d"
+  integrity sha512-IY0hMkVTNoqoYwKEp5UvNNKp/A5jeJUOrIO7judgOyhHT+xC6PA4VBOMAOhdtAYbMRHx9DTgI8p6Z6jhYQPFDA==
+  dependencies:
+    sax "1.2.1"
+
 int64-buffer@^0.1.9:
   version "0.1.10"
   resolved "https://registry.yarnpkg.com/int64-buffer/-/int64-buffer-0.1.10.tgz#277b228a87d95ad777d07c13832022406a473423"
@@ -173,6 +221,13 @@ ky@^0.19.0:
   resolved "https://registry.yarnpkg.com/ky/-/ky-0.19.0.tgz#d6ad117e89efe2d85a1c2e91462d48ca1cda1f7a"
   integrity sha512-RkDgbg5ahMv1MjHfJI2WJA2+Qbxq0iNSLWhreYiCHeHry9Q12sedCnP5KYGPt7sydDvsyH+8UcG6Kanq5mpsyw==
 
+lie@3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
+  integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=
+  dependencies:
+    immediate "~3.0.5"
+
 literallycanvas@^0.5.2:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/literallycanvas/-/literallycanvas-0.5.2.tgz#7d4800a8d9c4b38a593e91695d52466689586abd"
@@ -180,6 +235,13 @@ literallycanvas@^0.5.2:
   dependencies:
     react-addons-pure-render-mixin "^15.1"
 
+localforage@^1.7.1:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4"
+  integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==
+  dependencies:
+    lie "3.1.1"
+
 loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
@@ -273,6 +335,11 @@ requirejs@^2.3.6:
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
+sax@1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
+  integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o=
+
 setimmediate@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
diff --git a/bootstrap.php b/bootstrap.php
index faa798f6..4b67a0b7 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -64,6 +64,33 @@ function ovk_proc_strtr(string $string, int $length = 0): string
     return $newString . ($string !== $newString ? "…" : ""); #if cut hasn't happened, don't append "..."
 }
 
+function knuth_shuffle(iterable $arr, int $seed): array
+{
+    $data   = is_array($arr) ? $arr : iterator_to_array($arr);
+    $retVal = [];
+    $ind    = [];
+    $count  = sizeof($data);
+
+    srand($seed, MT_RAND_PHP);
+
+    for($i = 0; $i < $count; ++$i)
+        $ind[$i] = 0;
+
+    for($i = 0; $i < $count; ++$i) {
+        do {
+            $index = rand() % $count;
+        } while($ind[$index] != 0);
+
+        $ind[$index] = 1;
+        $retVal[$i] = $data[$index];
+    }
+
+    # Reseed
+    srand(hexdec(bin2hex(openssl_random_pseudo_bytes(4))));
+
+    return $retVal;
+}
+
 function bmask(int $input, array $options = []): Bitmask
 {
     return new Bitmask($input, $options["length"] ?? 1, $options["mappings"] ?? []);
diff --git a/composer.json b/composer.json
index c68dd4d1..a248197a 100644
--- a/composer.json
+++ b/composer.json
@@ -16,6 +16,7 @@
         "vearutop/php-obscene-censor-rus": "dev-master",
         "erusev/parsedown": "dev-master",
         "bhaktaraz/php-rss-generator": "dev-master",
+        "ext-openssl": "*",
         "ext-simplexml": "*",
         "symfony/console": "5.4.x-dev",
         "wapmorgan/morphos": "dev-master",
diff --git a/install/init-static-db.sql b/install/init-static-db.sql
index 78b5e64a..7678587e 100644
--- a/install/init-static-db.sql
+++ b/install/init-static-db.sql
@@ -54,27 +54,6 @@ CREATE TABLE `attachments` (
   `index` bigint(20) UNSIGNED NOT NULL
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
 
-CREATE TABLE `audios` (
-  `id` bigint(20) UNSIGNED NOT NULL,
-  `owner` bigint(20) UNSIGNED NOT NULL,
-  `virtual_id` bigint(20) UNSIGNED NOT NULL,
-  `created` bigint(20) UNSIGNED NOT NULL,
-  `edited` bigint(20) UNSIGNED DEFAULT NULL,
-  `hash` char(128) COLLATE utf8mb4_unicode_520_ci NOT NULL,
-  `deleted` tinyint(4) DEFAULT 0,
-  `name` varchar(190) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '(no name)',
-  `performer` varchar(190) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'Unknown',
-  `genre` varchar(190) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'K-POP',
-  `lyrics` longtext COLLATE utf8mb4_unicode_520_ci DEFAULT NULL,
-  `explicit` tinyint(4) NOT NULL DEFAULT 0
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
-
-CREATE TABLE `audio_relations` (
-  `user` bigint(20) UNSIGNED NOT NULL,
-  `audio` bigint(20) UNSIGNED NOT NULL,
-  `index` bigint(20) UNSIGNED NOT NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
-
 CREATE TABLE `comments` (
   `id` bigint(20) UNSIGNED NOT NULL,
   `owner` bigint(20) NOT NULL,
diff --git a/install/sqls/00041-music.sql b/install/sqls/00041-music.sql
new file mode 100644
index 00000000..49edbaf0
--- /dev/null
+++ b/install/sqls/00041-music.sql
@@ -0,0 +1,100 @@
+-- Apply these two commands if you installed OpenVK before 12th November 2023 OR if it's just doesn't work out of box, then apply this file again
+-- DROP TABLE `audios`;
+-- DROP TABLE `audio_relations`;
+
+CREATE TABLE IF NOT EXISTS `audios` (
+  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+  `owner` bigint unsigned NOT NULL,
+  `virtual_id` bigint unsigned NOT NULL,
+  `created` bigint unsigned NOT NULL,
+  `edited` bigint unsigned DEFAULT NULL,
+  `hash` char(128) NOT NULL,
+  `length` smallint unsigned NOT NULL,
+  `segment_size` decimal(20,6) NOT NULL DEFAULT '6.000000' COMMENT 'Size in seconds of each segment',
+  `kid` binary(16) NOT NULL,
+  `key` binary(16) NOT NULL,
+  `token` binary(28) NOT NULL COMMENT 'Key to access original file',
+  `listens` bigint unsigned NOT NULL DEFAULT '0',
+  `performer` varchar(256) NOT NULL,
+  `name` varchar(256) NOT NULL,
+  `lyrics` text,
+  `genre` enum('Blues','Big Band','Classic Rock','Chorus','Country','Easy Listening','Dance','Acoustic','Disco','Humour','Funk','Speech','Grunge','Chanson','Hip-Hop','Opera','Jazz','Chamber Music','Metal','Sonata','New Age','Symphony','Oldies','Booty Bass','Other','Primus','Pop','Porn Groove','R&B','Satire','Rap','Slow Jam','Reggae','Club','Rock','Tango','Techno','Samba','Industrial','Folklore','Alternative','Ballad','Ska','Power Ballad','Death Metal','Rhythmic Soul','Pranks','Freestyle','Soundtrack','Duet','Euro-Techno','Punk Rock','Ambient','Drum Solo','Trip-Hop','A Cappella','Vocal','Euro-House','Jazz+Funk','Dance Hall','Fusion','Goa','Trance','Drum & Bass','Classical','Club-House','Instrumental','Hardcore','Acid','Terror','House','Indie','Game','BritPop','Sound Clip','Negerpunk','Gospel','Polsk Punk','Noise','Beat','AlternRock','Christian Gangsta Rap','Bass','Heavy Metal','Soul','Black Metal','Punk','Crossover','Space','Contemporary Christian','Meditative','Christian Rock','Instrumental Pop','Merengue','Instrumental Rock','Salsa','Ethnic','Thrash Metal','Gothic','Anime','Darkwave','JPop','Techno-Industrial','Synthpop','Electronic','Abstract','Pop-Folk','Art Rock','Eurodance','Baroque','Dream','Bhangra','Southern Rock','Big Beat','Comedy','Breakbeat','Cult','Chillout','Gangsta Rap','Downtempo','Top 40','Dub','Christian Rap','EBM','Pop / Funk','Eclectic','Jungle','Electro','Native American','Electroclash','Cabaret','Emo','New Wave','Experimental','Psychedelic','Garage','Rave','Global','Showtunes','IDM','Trailer','Illbient','Lo-Fi','Industro-Goth','Tribal','Jam Band','Acid Punk','Krautrock','Acid Jazz','Leftfield','Polka','Lounge','Retro','Math Rock','Musical','New Romantic','Rock & Roll','Nu-Breakz','Hard Rock','Post-Punk','Folk','Post-Rock','Folk-Rock','Psytrance','National Folk','Shoegaze','Swing','Space Rock','Fast Fusion','Trop Rock','Bebob','World Music','Latin','Neoclassical','Revival','Audiobook','Celtic','Audio Theatre','Bluegrass','Neue Deutsche Welle','Avantgarde','Podcast','Gothic Rock','Indie Rock','Progressive Rock','G-Funk','Psychedelic Rock','Dubstep','Symphonic Rock','Garage Rock','Slow Rock','Psybient','Psychobilly','Touhou') DEFAULT NULL,
+  `explicit` tinyint(1) NOT NULL DEFAULT '0',
+  `withdrawn` tinyint(1) NOT NULL DEFAULT '0',
+  `processed` tinyint unsigned NOT NULL DEFAULT '0',
+  `checked` bigint NOT NULL DEFAULT '0' COMMENT 'Last time the audio availability was checked',
+  `unlisted` tinyint(1) NOT NULL DEFAULT '0',
+  `deleted` tinyint(1) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `owner_virtual_id` (`owner`,`virtual_id`),
+  KEY `genre` (`genre`),
+  KEY `unlisted` (`unlisted`),
+  KEY `listens` (`listens`),
+  KEY `deleted` (`deleted`),
+  KEY `length` (`length`),
+  KEY `listens_genre` (`listens`,`genre`),
+  FULLTEXT KEY `performer_name` (`performer`,`name`),
+  FULLTEXT KEY `lyrics` (`lyrics`),
+  FULLTEXT KEY `performer` (`performer`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+
+CREATE TABLE IF NOT EXISTS `audio_listens` (
+  `entity` bigint NOT NULL,
+  `audio` bigint unsigned NOT NULL,
+  `time` bigint unsigned NOT NULL,
+  `index` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'Workaround for Nette DBE bug',
+  `playlist` bigint(20) UNSIGNED DEFAULT NULL,
+  PRIMARY KEY (`index`),
+  KEY `audio` (`audio`),
+  KEY `user` (`entity`) USING BTREE,
+  KEY `user_time` (`entity`,`time`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+
+CREATE TABLE IF NOT EXISTS `audio_relations` (
+  `entity` bigint NOT NULL,
+  `audio` bigint unsigned NOT NULL,
+  `index` bigint unsigned NOT NULL AUTO_INCREMENT,
+  PRIMARY KEY (`index`),
+  KEY `user` (`entity`) USING BTREE,
+  KEY `entity_audio` (`entity`,`audio`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+
+CREATE TABLE IF NOT EXISTS `playlists` (
+  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+  `owner` bigint NOT NULL,
+  `name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL,
+  `description` varchar(2048) DEFAULT NULL,
+  `cover_photo_id` bigint unsigned DEFAULT NULL,
+  `length` int unsigned NOT NULL DEFAULT '0',
+  `special_type` tinyint unsigned NOT NULL DEFAULT '0',
+  `created` bigint unsigned DEFAULT NULL,
+  `listens` bigint(20) unsigned NOT NULL DEFAULT 0,
+  `edited` bigint unsigned DEFAULT NULL,
+  `deleted` tinyint unsigned DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `owner_deleted` (`owner`,`deleted`),
+  FULLTEXT KEY `title_description` (`name`,`description`),
+  FULLTEXT KEY `title` (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+
+CREATE TABLE IF NOT EXISTS `playlist_imports` (
+  `entity` bigint NOT NULL,
+  `playlist` bigint unsigned NOT NULL,
+  `index` bigint unsigned NOT NULL AUTO_INCREMENT,
+  PRIMARY KEY (`index`) USING BTREE,
+  KEY `user` (`entity`) USING BTREE,
+  KEY `entity_audio` (`entity`,`playlist`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+
+CREATE TABLE IF NOT EXISTS `playlist_relations` (
+  `collection` bigint unsigned NOT NULL,
+  `media` bigint unsigned NOT NULL,
+  `index` bigint unsigned NOT NULL AUTO_INCREMENT,
+  PRIMARY KEY (`index`) USING BTREE,
+  KEY `playlist` (`collection`) USING BTREE,
+  KEY `audio` (`media`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+
+ALTER TABLE `groups` ADD `everyone_can_upload_audios` TINYINT(1) NOT NULL DEFAULT '0' AFTER `backdrop_2`; 
+ALTER TABLE `profiles` ADD `last_played_track` BIGINT(20) UNSIGNED NULL DEFAULT NULL AFTER `client_name`, ADD `audio_broadcast_enabled` TINYINT(1) UNSIGNED NOT NULL DEFAULT '0' AFTER `last_played_track`; 
+ALTER TABLE `groups` ADD `last_played_track` BIGINT(20) UNSIGNED NULL DEFAULT NULL AFTER `everyone_can_upload_audios`, ADD `audio_broadcast_enabled` TINYINT(1) UNSIGNED NOT NULL DEFAULT '0' AFTER `last_played_track`; 
diff --git a/locales/en.strings b/locales/en.strings
index b1d211d1..7fbe255e 100644
--- a/locales/en.strings
+++ b/locales/en.strings
@@ -479,6 +479,7 @@
 "my_videos" = "My Videos";
 "my_messages" = "My Messages";
 "my_notes" = "My Notes";
+"my_audios" = "My Audios";
 "my_groups" = "My Groups";
 "my_feed" = "My Feed";
 "my_feedback" = "My Feedback";
@@ -568,6 +569,7 @@
 "privacy_setting_add_to_friends" = "Who can add me to friends";
 "privacy_setting_write_wall" = "Who can publish post on my wall";
 "privacy_setting_write_messages" = "Who can write messages to me";
+"privacy_setting_view_audio" = "Who can see my audios";
 "privacy_value_anybody" = "Anybody";
 "privacy_value_anybody_dative" = "Anybody";
 "privacy_value_users" = "OpenVK users";
@@ -713,12 +715,133 @@
 "upload_new_video" = "Upload new video";
 "max_attached_videos" = "Max is 10 videos";
 "max_attached_photos" = "Max is 10 photos";
+"max_attached_audios" = "Max is 10 audios";
 "no_videos" = "You don't have uploaded videos.";
 "no_videos_results" = "No results.";
 
 "change_video" = "Change video";
 "unknown_video" = "This video is not supported in your version of OpenVK.";
 
+
+/* Audios */
+
+"audios" = "Audios";
+"audio" = "Audio";
+"playlist" = "Playlist";
+"upload_audio" = "Upload audio";
+"upload_audio_to_group" = "Upload audio to group";
+
+"performer" = "Performer";
+"audio_name" = "Name";
+"genre" = "Genre";
+"lyrics" = "Lyrics";
+
+"select_another_file" = "Select another file";
+
+"limits" = "Limits";
+"select_audio" = "Select audio from your computer";
+"audio_requirements" = "Audio must be between $1s to $2 minutes, weights to $3MB and contain audio stream.";
+"audio_requirements_2" = "Audio must not infringe copyright and related rights";
+"you_can_also_add_audio_using" = "You can also add audio from among the files you have already downloaded using";
+"search_audio_inst" = "audios search";
+
+"audio_embed_not_found" = "Audio not found";
+"audio_embed_deleted" = "Audio was deleted";
+"audio_embed_withdrawn" = "The audio was withdrawn at the request of the copyright holder";
+"audio_embed_forbidden" = "The user's privacy settings do not allow this embed this audio";
+"audio_embed_processing" = "Audio is still being processed, or has not been processed correctly.";
+
+"audios_count_zero" = "No audios";
+"audios_count_one" = "One audio";
+"audios_count_few" = "$1 audios";
+"audios_count_many" = "$1 audios";
+"audios_count_other" = "$1 audios";
+
+"track_unknown" = "Unknown";
+"track_noname" = "Without name";
+
+"my_music" = "My music";
+"music_user" = "User's music";
+"music_club" = "Club's music";
+"audio_new" = "New";
+"audio_popular" = "Popular";
+"audio_search" = "Search";
+
+"my_audios_small" = "My audios";
+"my_playlists" = "My playlists";
+"playlists" = "Playlists";
+"audios_explicit" = "Contains obscene language";
+"withdrawn" = "Withdrawn";
+"deleted" = "Deleted";
+"owner" = "Owner";
+"searchable" = "Searchable";
+
+"select_audio" = "Select audios";
+"no_playlists_thisuser" = "You haven't added any playlists yet.";
+"no_playlists_user" = "This user has not added any playlists yet.";
+"no_playlists_club" = "This group hasn't added playlists yet.";
+
+"no_audios_thisuser" = "You haven't added any audios yet.";
+"no_audios_user" = "This user has not added any audios yet.";
+"no_audios_club" = "This group has not added any audios yet.";
+
+"new_playlist" = "New playlist";
+"created_playlist" = "created";
+"updated_playlist" = "updated";
+"bookmark" = "Add to collection";
+"unbookmark" = "Remove from collection";
+"empty_playlist" = "There are no audios in this playlist.";
+"edit_playlist" = "Edit playlist";
+"unable_to_load_queue" = "Error when loading queue.";
+
+"fully_delete_audio" = "Fully delete audio";
+"attach_audio" = "Attach audio";
+"detach_audio" = "Detach audio";
+
+"show_more_audios" = "Show more audios";
+"add_to_playlist" = "Add to playlist";
+"remove_from_playlist" = "Remove from playlist";
+"delete_playlist" = "Delete playlist";
+"playlist_cover" = "Playlist cover";
+
+"playlists_user" = "Users playlists";
+"playlists_club" = "Groups playlists";
+"change_cover" = "Change cover";
+"playlist_cover" = "Playlist's cover";
+
+"minutes_count_zero" = "lasts no minutes";
+"minutes_count_one" = "lasts one minute";
+"minutes_count_few" = "lasts $1 minutes";
+"minutes_count_many" = "lasts $1 minutes";
+"minutes_count_other" = "lasts $1 minutes";
+
+"listens_count_zero" = "no listens";
+"listens_count_one" = "one listen";
+"listens_count_few" = "$1 listens";
+"listens_count_many" = "$1 listens";
+"listens_count_other" = "$1 listens";
+
+"add_audio_to_club" = "Add audio to group";
+"what_club_add" = "Which group do you want to add the song to?";
+"group_has_audio" = "This group already has this song.";
+"group_hasnt_audio" = "This group doesn't have this song.";
+
+"by_name" = "by name";
+"by_performer" = "by performer";
+"no_access_clubs" = "There are no groups where you are an administrator.";
+"audio_successfully_uploaded" = "Audio has been successfully uploaded and is currently being processed.";
+
+"broadcast_audio" = "Broadcast audio to status";
+"sure_delete_playlist" = "Do you sure want to delete this playlist?";
+"edit_audio" = "Edit audio";
+"audios_group" = "Audios from group";
+"playlists_group" = "Playlists from group";
+
+"play_tip" = "Play/pause";
+"repeat_tip" = "Repeat";
+"shuffle_tip" = "Shuffle";
+"mute_tip" = "Mute";
+
 /* Notifications */
 
 "feedback" = "Feedback";
@@ -983,6 +1106,7 @@
 "going_to_report_photo" = "You are about to report this photo.";
 "going_to_report_user" = "You are about to report this user.";
 "going_to_report_video" = "You are about to report this video.";
+"going_to_report_audio" = "You are about to report this audio.";
 "going_to_report_post" = "You are about to report this post.";
 "going_to_report_comment" = "You are about to report this comment.";
 
@@ -1111,6 +1235,7 @@
 "created" = "Created";
 
 "everyone_can_create_topics" = "Everyone can create topics";
+"everyone_can_upload_audios" = "Everyone can upload audios";
 "display_list_of_topics_above_wall" = "Display a list of topics above the wall";
 
 "topic_changes_saved_comment" = "The updated title and settings will appear on the topic page.";
@@ -1287,6 +1412,22 @@
 
 "description_too_long" = "Description is too long.";
 
+"invalid_audio" = "Invalid audio.";
+"do_not_have_audio" = "You don't have this audio.";
+"do_have_audio" = "You already have this audio.";
+
+"set_playlist_name" = "Enter the playlist name.";
+"playlist_already_bookmarked" = "This playlist is already in your collection.";
+"playlist_not_bookmarked" = "This playlist is not in your collection.";
+"invalid_cover_photo" = "Error when loading cover photo.";
+"not_a_photo" = "Uploaded file doesn't look like a photo.";
+"file_too_big" = "File is too big.";
+"file_loaded_partially" = "The file has been uploaded partially.";
+"file_not_uploaded" = "Failed to upload the file.";
+"error_code" = "Error code: $1.";
+"ffmpeg_timeout" = "Timed out waiting ffmpeg. Try to upload file again.";
+"ffmpeg_not_installed" = "Failed to proccess the file. It looks like ffmpeg is not installed on this server.";
+
 /* Admin actions */
 
 "login_as" = "Login as $1";
@@ -1393,6 +1534,10 @@
 
 "admin_gift_moved_successfully" = "Gift moved successfully";
 "admin_gift_moved_to_recycle" = "This gift will now be in <b>Recycle Bin</b>.";
+"admin_original_file" = "Original file";
+"admin_audio_length" = "Length";
+"admin_cover_id" = "Cover (photo ID)";
+"admin_music" = "Music";
 
 "logs" = "Logs";
 "logs_anything" = "Anything";
@@ -1620,8 +1765,16 @@
 "tour_section_5_text_3" = "In addition to uploading videos directly, the site also supports embedding videos from YouTube";
 
 
-"tour_section_6_title_1" = "Audios section, which doesn't exist yet xdddd";
-"tour_section_6_text_1" = "I would love to do a tutorial on this section, but sunshine Vriska didn't make the music :c";
+"tour_section_6_title_1" = "Listen to music";
+"tour_section_6_text_1" = "You can listen to music in \"My Audios\"";
+"tour_section_6_text_2" = "This section is also controlled by the privacy settings.";
+"tour_section_6_text_3" = "The most listened songs are in \"Popular\", and recently uploaded songs are in \"New\"";
+"tour_section_6_text_4" = "To add a song to your collection, hover over it and click on the \"plus\". You can search for the song you want.";
+"tour_section_6_text_5" = "If you can't find the song you want, you can upload it yourself";
+"tour_section_6_bottom_text_1" = "<b>Important:</b> the song must not infringe copyright";
+"tour_section_6_title_2" = "Create playlists";
+"tour_section_6_text_6" = "You can create playlists in the \"My Playlists\" tab";
+"tour_section_6_text_7" = "You can also add another's playlists to your collection";
 
 
 "tour_section_7_title_1" = "Follow what your friends write";
@@ -1732,6 +1885,8 @@
 "s_order_by_name" = "By name";
 "s_order_by_random" = "By random";
 "s_order_by_rating" = "By rating";
+"s_order_by_length" = "By length";
+"s_order_by_listens" = "By listens count";
 "s_order_invert" = "Invert";
 
 "s_by_date" = "By date";
@@ -1753,6 +1908,8 @@
 "deleted_target_comment" = "This comment belongs to deleted post";
 
 "no_results" = "No results";
+"s_only_performers" = "Performers only";
+"s_with_lyrics" = "With lyrics";
 
 /* BadBrowser */
 
@@ -1792,6 +1949,7 @@
 /* Mobile */
 "mobile_friends" = "Friends";
 "mobile_photos" = "Photos";
+"mobile_audios" = "Audios";
 "mobile_videos" = "Videos";
 "mobile_messages" = "Messages";
 "mobile_notes" = "Notes";
@@ -1805,6 +1963,9 @@
 "mobile_user_info_hide" = "Hide";
 "mobile_user_info_show_details" = "Show details";
 
+"my" = "My";
+"enter_a_name_or_artist" = "Enter a name or artist...";
+
 /* Moderation */
 
 "section" = "Section";
diff --git a/locales/ru.strings b/locales/ru.strings
index 49871399..63f40462 100644
--- a/locales/ru.strings
+++ b/locales/ru.strings
@@ -461,6 +461,7 @@
 "my_videos" = "Мои Видеозаписи";
 "my_messages" = "Мои Сообщения";
 "my_notes" = "Мои Заметки";
+"my_audios" = "Мои Аудиозаписи";
 "my_groups" = "Мои Группы";
 "my_feed" = "Мои Новости";
 "my_feedback" = "Мои Ответы";
@@ -540,6 +541,7 @@
 "privacy_setting_add_to_friends" = "Кто может называть меня другом";
 "privacy_setting_write_wall" = "Кто может писать у меня на стене";
 "privacy_setting_write_messages" = "Кто может писать мне сообщения";
+"privacy_setting_view_audio" = "Кому видно мои аудиозаписи";
 "privacy_value_anybody" = "Все желающие";
 "privacy_value_anybody_dative" = "Всем желающим";
 "privacy_value_users" = "Пользователям OpenVK";
@@ -672,9 +674,128 @@
 "upload_new_video" = "Загрузить новое видео";
 "max_attached_videos" = "Максимум 10 видеозаписей";
 "max_attached_photos" = "Максимум 10 фотографий";
+"max_attached_audios" = "Максимум 10 аудиозаписей";
 "no_videos" = "У вас нет видео.";
 "no_videos_results" = "Нет результатов.";
 
+/* Audios */
+
+"audios" = "Аудиозаписи";
+"audio" = "Аудиозапись";
+"playlist" = "Плейлист";
+"upload_audio" = "Загрузить аудио";
+"upload_audio_to_group" = "Загрузить аудио в группу";
+
+"performer" = "Исполнитель";
+"audio_name" = "Название";
+"genre" = "Жанр";
+"lyrics" = "Текст";
+
+"select_another_file" = "Выбрать другой файл";
+
+"limits" = "Ограничения";
+"select_audio" = "Выберите аудиозапись на Вашем компьютере";
+"audio_requirements" = "Аудиозапись должна быть длинной от $1c до $2 минут, весить до $3мб и содержать аудиопоток.";
+"audio_requirements_2" = "Аудиозапись не должна нарушать авторские и смежные права.";
+"you_can_also_add_audio_using" = "Вы также можете добавить аудиозапись из числа уже загруженных файлов, воспользовавшись";
+"search_audio_inst" = "поиском по аудио";
+
+"audio_embed_not_found" = "Аудиозапись не найдена";
+"audio_embed_deleted" = "Аудиозапись была удалена";
+"audio_embed_withdrawn" = "Аудиозапись была изъята по обращению правообладателя.";
+"audio_embed_forbidden" = "Настройки приватности пользователя не позволяют встраивать эту композицию";
+"audio_embed_processing" = "Аудио ещё обрабатывается, либо обработалось неправильно.";
+
+"audios_count_zero" = "Нет аудиозаписей";
+"audios_count_one" = "Одна аудиозапись"; /* сингл */
+"audios_count_few" = "$1 аудиозаписи";
+"audios_count_many" = "$1 аудиозаписей";
+"audios_count_other" = "$1 аудиозаписей";
+
+"track_unknown" = "Неизвестен";
+"track_noname" = "Без названия";
+
+"my_music" = "Моя музыка";
+"music_user" = "Музыка пользователя";
+"music_club" = "Музыка группы";
+"audio_new" = "Новое";
+"audio_popular" = "Популярное";
+"audio_search" = "Поиск";
+
+"my_audios_small" = "Мои аудиозаписи";
+"my_playlists" = "Мои плейлисты";
+"playlists" = "Плейлисты";
+"audios_explicit" = "Содержит нецензурную лексику";
+"withdrawn" = "Изъято";
+"deleted" = "Удалено";
+"owner" = "Владелец";
+"searchable" = "Доступно в поиске";
+
+"select_audio" = "Выбрать аудиозаписи";
+"no_playlists_thisuser" = "Вы ещё не добавляли плейлистов.";
+"no_playlists_user" = "Этот пользователь ещё не добавлял плейлистов.";
+"no_playlists_club" = "Эта группа ещё не добавляла плейлистов.";
+
+"no_audios_thisuser" = "Вы ещё не добавляли аудиозаписей.";
+"no_audios_user" = "Этот пользователь ещё не добавлял аудиозаписей.";
+"no_audios_club" = "Эта группа ещё не добавляла аудиозаписей.";
+
+"new_playlist" = "Новый плейлист";
+"created_playlist" = "создан";
+"updated_playlist" = "обновлён";
+"bookmark" = "Добавить в коллекцию";
+"unbookmark" = "Убрать из коллекции";
+"empty_playlist" = "В этом плейлисте нет аудиозаписей.";
+"edit_playlist" = "Редактировать плейлист";
+"unable_to_load_queue" = "Не удалось загрузить очередь.";
+
+"fully_delete_audio" = "Полностью удалить аудиозапись";
+"attach_audio" = "Прикрепить аудиозапись";
+"detach_audio" = "Открепить аудиозапись";
+
+"show_more_audios" = "Показать больше аудиозаписей";
+"add_to_playlist" = "Добавить в плейлист";
+"remove_from_playlist" = "Удалить из плейлиста";
+"delete_playlist" = "Удалить плейлист";
+"playlist_cover" = "Обложка плейлиста";
+"playlists_user" = "Плейлисты польз.";
+"playlists_club" = "Плейлисты группы";
+"change_cover" = "Сменить обложку";
+"playlist_cover" = "Обложка плейлиста";
+
+"minutes_count_zero" = "длится ноль минут";
+"minutes_count_one" = "длится одну минуту";
+"minutes_count_few" = "длится $1 минуты";
+"minutes_count_many" = "длится $1 минут";
+"minutes_count_other" = "длится $1 минут";
+
+"listens_count_zero" = "нет прослушиваний";
+"listens_count_one" = "одно прослушивание";
+"listens_count_few" = "$1 прослушивания";
+"listens_count_many" = "$1 прослушиваний";
+"listens_count_other" = "$1 прослушиваний";
+
+"add_audio_to_club" = "Добавить аудио в группу";
+"what_club_add" = "В какую группу вы хотите добавить песню?";
+"group_has_audio" = "У группы уже есть эта песня.";
+"group_hasnt_audio" = "У группы нет этой песни.";
+
+"by_name" = "по композициям";
+"by_performer" = "по исполнителю";
+"no_access_clubs" = "Нет групп, где вы являетесь администратором.";
+"audio_successfully_uploaded" = "Аудио успешно загружено и на данный момент обрабатывается.";
+
+"broadcast_audio" = "Транслировать аудио в статус";
+"sure_delete_playlist" = "Вы действительно хотите удалить этот плейлист?";
+"edit_audio" = "Редактировать аудиозапись";
+"audios_group" = "Аудиозаписи группы";
+"playlists_group" = "Плейлисты группы";
+
+"play_tip" = "Проигрывание/пауза";
+"repeat_tip" = "Повторение";
+"shuffle_tip" = "Перемешать";
+"mute_tip" = "Заглушить";
+
 /* Notifications */
 
 "feedback" = "Ответы";
@@ -915,6 +1036,7 @@
 "going_to_report_photo" = "Вы собираетесь пожаловаться на данную фотографию.";
 "going_to_report_user" = "Вы собираетесь пожаловаться на данного пользователя.";
 "going_to_report_video" = "Вы собираетесь пожаловаться на данную видеозапись.";
+"going_to_report_audio" = "Вы собираетесь пожаловаться на данную аудиозапись.";
 "going_to_report_post" = "Вы собираетесь пожаловаться на данную запись.";
 "going_to_report_comment" = "Вы собираетесь пожаловаться на данный комментарий.";
 
@@ -1030,6 +1152,7 @@
 "topics_other" = "$1 тем";
 "created" = "Создано";
 "everyone_can_create_topics" = "Все могут создавать темы";
+"everyone_can_upload_audios" = "Все могут загружать аудиозаписи";
 "display_list_of_topics_above_wall" = "Отображать список тем над стеной";
 "topic_changes_saved_comment" = "Обновлённый заголовок и настройки появятся на странице с темой.";
 "failed_to_create_topic" = "Не удалось создать тему";
@@ -1047,6 +1170,7 @@
 "no_data" = "Нет данных";
 "no_data_description" = "Тут ничего нет... Пока...";
 "error" = "Ошибка";
+"error_generic" = "Произошла ошибка общего характера: ";
 "error_shorturl" = "Данный короткий адрес уже занят.";
 "error_segmentation" = "Ошибка сегментации";
 "error_upload_failed" = "Не удалось загрузить фото";
@@ -1055,6 +1179,8 @@
 "error_weak_password" = "Ненадёжный пароль. Пароль должен содержать не менее 8 символов, цифры, прописные и строчные буквы";
 "error_shorturl_incorrect" = "Короткий адрес имеет некорректный формат.";
 "error_repost_fail" = "Не удалось поделиться записью";
+
+"error_insufficient_info" = "Вы не указали необходимую информацию.";
 "error_data_too_big" = "Аттрибут '$1' не может быть длиннее $2 $3";
 "forbidden" = "Ошибка доступа";
 "unknown_error" = "Неизвестная ошибка";
@@ -1181,6 +1307,21 @@
 "group_owner_is_banned" = "Создатель сообщества успешно забанен.";
 "group_is_banned" = "Сообщество успешно забанено";
 "description_too_long" = "Описание слишком длинное.";
+"invalid_audio" = "Такой аудиозаписи не существует.";
+"do_not_have_audio" = "У вас нет этой аудиозаписи.";
+"do_have_audio" = "У вас уже есть эта аудиозапись.";
+
+"set_playlist_name" = "Укажите название плейлиста.";
+"playlist_already_bookmarked" = "Плейлист уже есть в вашей коллекции.";
+"playlist_not_bookmarked" = "Плейлиста нет в вашей коллекции.";
+"invalid_cover_photo" = "Не удалось сохранить обложку плейлиста.";
+"not_a_photo" = "Загруженный файл не похож на фотографию.";
+"file_too_big" = "Файл слишком большой.";
+"file_loaded_partially" = "Файл загрузился частично.";
+"file_not_uploaded" = "Не удалось загрузить файл.";
+"error_code" = "Код ошибки: $1.";
+"ffmpeg_timeout" = "Превышено время ожидания обработки ffmpeg. Попробуйте загрузить файл снова.";
+"ffmpeg_not_installed" = "Не удалось обработать файл. Похоже, на сервере не установлен ffmpeg.";
 
 /* Admin actions */
 
@@ -1277,6 +1418,10 @@
 
 "admin_gift_moved_successfully" = "Подарок успешно перемещён";
 "admin_gift_moved_to_recycle" = "Теперь подарок находится в <b>корзине</b>.";
+"admin_original_file" = "Оригинальный файл";
+"admin_audio_length" = "Длина";
+"admin_cover_id" = "Обложка (ID фото)";
+"admin_music" = "Музыка";
 
 "logs" = "Логи";
 "logs_anything" = "Любое";
@@ -1508,8 +1653,16 @@
 "tour_section_5_text_3" = "Кроме загрузки видео напрямую, сайт поддерживает и встраивание видео из YouTube";
 
 
-"tour_section_6_title_1" = "Аудиозаписи, которых пока что нет XD";
-"tour_section_6_text_1" = "Я был бы очень рад сделать туториал по этому разделу, но солнышко Вриска не сделала музыку";
+"tour_section_6_title_1" = "Слушайте аудиозаписи";
+"tour_section_6_text_1" = "Вы можете слушать аудиозаписи в разделе \"Мои Аудиозаписи\"";
+"tour_section_6_text_2" = "Этот раздел также регулируется настройками приватности";
+"tour_section_6_text_3" = "Самые прослушиваемые песни находятся во вкладке \"Популярное\", а недавно загруженные — во вкладке \"Новое\"";
+"tour_section_6_text_4" = "Чтобы добавить песню в свою коллекцию, наведите на неё и нажмите на плюс. Найти нужную песню можно в поиске";
+"tour_section_6_text_5" = "Если вы не можете найти нужную песню, вы можете загрузить её самостоятельно";
+"tour_section_6_bottom_text_1" = "<b>Важно:</b> песня не должна нарушать авторские права";
+"tour_section_6_title_2" = "Создавайте плейлисты";
+"tour_section_6_text_6" = "Вы можете создавать сборники треков во вкладке \"Мои плейлисты\"";
+"tour_section_6_text_7" = "Можно также добавлять чужие плейлисты в свою коллекцию";
 
 
 "tour_section_7_title_1" = "Следите за тем, что пишут ваши друзья";
@@ -1620,6 +1773,8 @@
 "s_order_by_name" = "По имени";
 "s_order_by_random" = "По случайности";
 "s_order_by_rating" = "По рейтингу";
+"s_order_by_length" = "По длине";
+"s_order_by_listens" = "По числу прослушиваний";
 "s_order_invert" = "Инвертировать";
 
 "s_by_date" = "По дате";
@@ -1641,6 +1796,8 @@
 "deleted_target_comment" = "Этот комментарий принадлежит к удалённой записи";
 
 "no_results" = "Результатов нет";
+"s_only_performers" = "Только исполнители";
+"s_with_lyrics" = "С текстом";
 
 /* BadBrowser */
 
@@ -1680,6 +1837,7 @@
 /* Mobile */
 "mobile_friends" = "Друзья";
 "mobile_photos" = "Фотографии";
+"mobile_audios" = "Аудиозаписи";
 "mobile_videos" = "Видеозаписи";
 "mobile_messages" = "Сообщения";
 "mobile_notes" = "Заметки";
@@ -1693,6 +1851,9 @@
 "mobile_user_info_hide" = "Скрыть";
 "mobile_user_info_show_details" = "Показать подробнее";
 
+"my" = "Мои";
+"enter_a_name_or_artist" = "Введите название или автора...";
+
 /* Moderation */
 
 "section" = "Раздел";
diff --git a/openvk-example.yml b/openvk-example.yml
index 4b45e7ba..308610e2 100644
--- a/openvk-example.yml
+++ b/openvk-example.yml
@@ -50,6 +50,8 @@ openvk:
                 - "Good luck filling! If you are a regular support agent, inform the administrator that he forgot to fill the config"
         messages:
             strict: false
+        music:
+            exposeOriginalURLs: true
         wall:
             christian: false
             anonymousPosting:
diff --git a/themepacks/midnight/stylesheet.css b/themepacks/midnight/stylesheet.css
index 634917c6..73030b40 100644
--- a/themepacks/midnight/stylesheet.css
+++ b/themepacks/midnight/stylesheet.css
@@ -235,10 +235,133 @@ input[type="radio"] {
 }
 
 .searchList #used {
-    background: linear-gradient(#453e5e,#473f61);
+    background: #463f60 !important;
 }
 
 #backdropEditor {
     background-image: url("/themepack/midnight/0.0.2.8/resource/backdrop-editor.gif") !important;
     border-color: #473e66 !important;
-}
\ No newline at end of file
+}
+
+.bigPlayer {
+    background-color: rgb(30, 26, 43) !important;
+}
+
+.bigPlayer .selectableTrack, .audioEmbed .track > .selectableTrack {
+    border-top: #b9b9b9 1px solid !important;
+}
+
+.bigPlayer .paddingLayer .slider, .audioEmbed .track .slider {
+    background: #b9b9b9 !important;
+}
+
+.musicIcon {
+    filter: invert(81%) !important;
+}
+
+.audioEntry.nowPlaying {
+    background: #463f60 !important;
+    border: 1px solid #645a86 !important;
+}
+
+.preformer {
+    color: #b7b7b7 !important;
+}
+
+.bigPlayer .paddingLayer .trackPanel .track .timeTip {
+    background: #b9b9b9 !important;
+    color: black !important;
+}
+
+.audioEntry.nowPlaying:hover {
+    background: #50486f !important;
+}
+
+.audioEntry:hover {
+    background: #19142D !important;
+}
+
+.audioEntry .performer a {
+    color: #a2a1a1 !important;
+}
+
+.musicIcon.lagged {
+    opacity: 49%;
+}
+
+.bigPlayer .paddingLayer .bigPlayerTip {
+    color: black !important;
+}
+
+.searchList a {
+    color: #bbb !important;
+}
+
+.searchList a:hover {
+    color: #eeeeee !important;
+    background: #332d46 !important;
+}
+
+.friendsAudiosList .elem:hover {
+    background: #332d46 !important;
+}
+
+.audioEntry .playerButton .playIcon {
+    filter: invert(81%);
+}
+
+img[src$='/assets/packages/static/openvk/img/camera_200.png'], img[src$='/assets/packages/static/openvk/img/song.jpg']  {
+    filter: invert(100%);
+}
+
+.audioStatus {
+    color: #8E8E8E !important;
+}
+
+.audioEntry .withLyrics {
+    color: #6f6497 !important;
+}
+
+#listensCount {
+    color: unset !important;
+}
+
+#upload_container, .whiteBox {
+    background: #1d1928 !important;
+    border: 1px solid #383052 !important;
+}
+
+ul {
+    color: #8b9ab5 !important;
+}
+
+#audio_upload {
+    border: 2px solid #383052 !important;
+    background-color: #262133 !important;
+}
+
+/* вот бы css в овк был бы написан на var()'ах( */
+#upload_container.uploading {
+    background: #121017 url('/assets/packages/static/openvk/img/progressbar.gif') !important;
+}
+
+.musicIcon.pressed {
+    opacity: 41% !important;
+}
+
+.ovk-diag-body .searchBox {
+    background: #1e1a2b !important;
+}
+
+.audioEntry.nowPlaying .title {
+    color: #fff !important;
+}
+
+.attachAudio:hover {
+    background: #19142D !important;
+    cursor: pointer;
+}
+
+.showMore, .showMoreAudiosPlaylist {
+    background: #181826 !important;
+}
diff --git a/themepacks/openvk_modern/stylesheet.css b/themepacks/openvk_modern/stylesheet.css
index 178e8094..841e92ec 100644
--- a/themepacks/openvk_modern/stylesheet.css
+++ b/themepacks/openvk_modern/stylesheet.css
@@ -279,18 +279,9 @@ input[type=checkbox] {
     box-shadow: none;
 }
 
-.searchList #used
-{
-    margin-left:0px;
-    color: white;
-    padding: 2px;
-    padding-top: 5px;
-    padding-bottom: 5px;
-    border: none;
-    background: #a4a4a4;
-    margin-bottom: 2px;
-    padding-left: 5px;
-    width: 90%;
+.searchList #used {
+    background: #3c3c3c !important;
+    border: unset !important;
 }
 
 .searchList #used a
@@ -337,3 +328,35 @@ input[type=checkbox] {
 {
     border-top: 1px solid #2f2f2f;
 }
+
+.musicIcon {
+    filter: contrast(202%) !important;
+}
+
+.audioEntry .playerButton .playIcon {
+    filter: contrast(7) !important;
+}
+
+.audioEmbed .track > .selectableTrack, .bigPlayer .selectableTrack {
+    border-top: #404040 1px solid !important;
+} 
+
+.bigPlayer .paddingLayer .slider, .audioEmbed .track .slider {
+    background: #3c3c3c !important;
+}
+
+.audioEntry.nowPlaying {
+    background: #4b4b4b !important;
+}
+
+.audioEntry.nowPlaying:hover {
+    background: #373737 !important;
+}
+
+.musicIcon.pressed {
+    filter: brightness(150%) !important;
+}
+
+.musicIcon.lagged {
+    opacity: 50%;
+}

From eb64376c3a02356c771280353c486e789521afe7 Mon Sep 17 00:00:00 2001
From: DeathPleiad <43928323+Parad1seF0x@users.noreply.github.com>
Date: Sun, 12 Nov 2023 03:14:01 +0300
Subject: [PATCH 10/21] Add -vn flag to FFMPEG scripts (#1020)

---
 Web/Models/shell/processAudio.ps1 | 2 +-
 Web/Models/shell/processAudio.sh  | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/Web/Models/shell/processAudio.ps1 b/Web/Models/shell/processAudio.ps1
index 206e11e1..f60a9aed 100644
--- a/Web/Models/shell/processAudio.ps1
+++ b/Web/Models/shell/processAudio.ps1
@@ -23,7 +23,7 @@ Set-Location -Path $temp
 
 Move-Item $filename $audioFile
 ffmpeg -i $audioFile -f dash -encryption_scheme cenc-aes-ctr -encryption_key $key `
-    -encryption_kid $keyID -map 0:a -c:a aac -ar 44100 -seg_duration $seg `
+    -encryption_kid $keyID -map 0:a -vn -c:a aac -ar 44100 -seg_duration $seg `
     -use_timeline 1 -use_template 1 -init_seg_name ($fileHash + '_fragments/0_0.$ext$') `
     -media_seg_name ($fileHash + '_fragments/chunk$Number%06d$_$RepresentationID$.$ext$') -adaptation_sets 'id=0,streams=a' `
     "$fileHash.mpd"
diff --git a/Web/Models/shell/processAudio.sh b/Web/Models/shell/processAudio.sh
index ab5a5c55..fa8346e0 100644
--- a/Web/Models/shell/processAudio.sh
+++ b/Web/Models/shell/processAudio.sh
@@ -19,7 +19,7 @@ cd "$temp"
 
 mv "$filename" "$audioFile"
 ffmpeg -i "$audioFile" -f dash -encryption_scheme cenc-aes-ctr -encryption_key "$key" \
-    -encryption_kid "$keyID" -map 0 -c:a aac -ar 44100 -seg_duration "$seg" \
+    -encryption_kid "$keyID" -map 0 -vn -c:a aac -ar 44100 -seg_duration "$seg" \
     -use_timeline 1 -use_template 1 -init_seg_name "$fileHash"_fragments/0_0."\$ext\$" \
     -media_seg_name "$fileHash"_fragments/chunk"\$Number"%06d\$_"\$RepresentationID\$"."\$ext\$" -adaptation_sets 'id=0,streams=a' \
     "$fileHash.mpd"

From f65d790654bb716af1d74b38031781701327d5e1 Mon Sep 17 00:00:00 2001
From: lalka2018 <99399973+lalka2016@users.noreply.github.com>
Date: Sun, 12 Nov 2023 12:58:04 +0300
Subject: [PATCH 11/21] Set context timeout to 20s and maybe fix broadcas

t list
---
 Web/Models/Entities/User.php | 2 +-
 Web/static/js/al_music.js    | 3 ++-
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php
index 291dfb6a..4ff3236b 100644
--- a/Web/Models/Entities/User.php
+++ b/Web/Models/Entities/User.php
@@ -1279,7 +1279,7 @@ class User extends RowModel
         foreach($entityIds as $id) {
             $entit = $id > 0 ? (new Users)->get($id) : (new Clubs)->get(abs($id));
 
-            if($id > 0 && $entit->isDeleted()) return;
+            if($id > 0 && $entit->isDeleted()) continue;
             $returnArr[] = $entit;
         }
 
diff --git a/Web/static/js/al_music.js b/Web/static/js/al_music.js
index fbbe7c82..85952a00 100644
--- a/Web/static/js/al_music.js
+++ b/Web/static/js/al_music.js
@@ -141,7 +141,8 @@ class bigPlayer {
                     }
                 ]
             }, 
-            body: formdata
+            body: formdata,
+            timeout: 20000,
         })
 
         u(this.nodes["playButtons"].querySelector(".playButton")).on("click", (e) => {

From 5bb6e097fb0c03117817657d53b96edc739aafae Mon Sep 17 00:00:00 2001
From: Evgeniy Khramov <65224669+thejenja@users.noreply.github.com>
Date: Sun, 12 Nov 2023 17:04:13 +0500
Subject: [PATCH 12/21] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20=D0=BA=D1=80?=
 =?UTF-8?q?=D1=83=D0=B3=D0=BB=D1=8B=D1=85=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0?=
 =?UTF-8?q?=D1=80=D0=BE=D0=BA=20=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?=
 =?UTF-8?q?=D0=B5=20=D0=BC=D1=83=D0=B7=D1=8B=D0=BA=D0=B8=20=D0=B4=D1=80?=
 =?UTF-8?q?=D1=83=D0=B7=D0=B5=D0=B9=20(#1024)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 Web/static/css/avatar.2.css | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/Web/static/css/avatar.2.css b/Web/static/css/avatar.2.css
index 2990eb3d..42daa14d 100644
--- a/Web/static/css/avatar.2.css
+++ b/Web/static/css/avatar.2.css
@@ -81,3 +81,10 @@ div.ovk-video > div > img
     object-fit: cover;
     border-radius: 100px;
 }
+
+.friendsAudiosList .elem img {
+    width: 30px;
+    border-radius: 100px;
+    height: 31px;
+    min-width: 30px;
+}

From 1632d54d52065681d1f2e822ce4ad9132d1bc737 Mon Sep 17 00:00:00 2001
From: lalka2018 <99399973+lalka2016@users.noreply.github.com>
Date: Sun, 12 Nov 2023 15:46:00 +0300
Subject: [PATCH 13/21] ya eblan
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Исправлен server error из-за списка друзей. Он подгружает абсолютно все группы и всех друзей, на которые подписан человек и из-за этого серверу очень плохо. Забыл, бывает. Теперь он подгружает только 10 такого
---
 Web/Models/Entities/User.php | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php
index 4ff3236b..66b93713 100644
--- a/Web/Models/Entities/User.php
+++ b/Web/Models/Entities/User.php
@@ -1267,6 +1267,8 @@ class User extends RowModel
             $entityIds[] = $_rel->model == "openvk\\Web\\Models\\Entities\\Club" ? $_rel->target * -1 : $_rel->target;
         }
 
+        $entityIds = array_slice($entityIds, 0, 10);
+
         if($shuffle) {
             $shuffleSeed    = openssl_random_pseudo_bytes(6);
             $shuffleSeed    = hexdec(bin2hex($shuffleSeed));

From 08499cd3b4dd3ff575e6819fd8931d20f6647f7a Mon Sep 17 00:00:00 2001
From: lalka2018 <99399973+lalka2016@users.noreply.github.com>
Date: Sun, 12 Nov 2023 17:02:33 +0300
Subject: [PATCH 14/21] Some broadcast list fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- При скроллинге вниз на странице с аудио вкладки не идут за вами (баговано)
- Теперь перемешка списка друзей на странице аудио должна работать нормально
---
 Web/Models/Entities/User.php | 4 ++--
 Web/static/js/al_music.js    | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php
index 66b93713..40ef7aef 100644
--- a/Web/Models/Entities/User.php
+++ b/Web/Models/Entities/User.php
@@ -1267,14 +1267,14 @@ class User extends RowModel
             $entityIds[] = $_rel->model == "openvk\\Web\\Models\\Entities\\Club" ? $_rel->target * -1 : $_rel->target;
         }
 
-        $entityIds = array_slice($entityIds, 0, 10);
-
         if($shuffle) {
             $shuffleSeed    = openssl_random_pseudo_bytes(6);
             $shuffleSeed    = hexdec(bin2hex($shuffleSeed));
     
             $entityIds = knuth_shuffle($entityIds, $shuffleSeed);
         }
+        
+        $entityIds = array_slice($entityIds, 0, 10);
 
         $returnArr = [];
         
diff --git a/Web/static/js/al_music.js b/Web/static/js/al_music.js
index 85952a00..691b6edb 100644
--- a/Web/static/js/al_music.js
+++ b/Web/static/js/al_music.js
@@ -651,10 +651,10 @@ document.addEventListener("DOMContentLoaded", function() {
             entries.forEach(x => {
                 if(x.isIntersecting) {
                     document.querySelector('.bigPlayer').classList.remove("floating")
-                    document.querySelector('.searchOptions .searchList').classList.remove("floating")
+                    //document.querySelector('.searchOptions .searchList').classList.remove("floating")
                     document.querySelector('.bigPlayerDetector').style.marginTop = "0px"
                 } else {
-                    document.querySelector('.searchOptions .searchList').classList.add("floating")
+                    //document.querySelector('.searchOptions .searchList').classList.add("floating")
                     document.querySelector('.bigPlayer').classList.add("floating")
                     document.querySelector('.bigPlayerDetector').style.marginTop = "46px"
                 }

From b7160d78a04c53f0dca5d231a89f12b7c10fa34f Mon Sep 17 00:00:00 2001
From: veselcraft <veselcraft@icloud.com>
Date: Sun, 12 Nov 2023 23:00:19 +0300
Subject: [PATCH 15/21] Global: Gender -> Sex

---
 locales/en.strings | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/locales/en.strings b/locales/en.strings
index 7fbe255e..53385ff9 100644
--- a/locales/en.strings
+++ b/locales/en.strings
@@ -72,7 +72,7 @@
 "change_status" = "change status";
 "name" = "Name";
 "surname" = "Surname";
-"gender" = "Gender";
+"gender" = "Sex";
 "male" = "male";
 "female" = "female";
 "description" = "Description";
@@ -1457,7 +1457,7 @@
 "admin_verification" = "Verification";
 "admin_banreason" = "Ban reason";
 "admin_banned" = "banned";
-"admin_gender" = "Gender";
+"admin_gender" = "Sex";
 "admin_registrationdate" = "Registration date";
 "admin_actions" = "Actions";
 "admin_image" = "Image";

From 784b19aaf76708e7bb39b4ec8a83e29892ba2477 Mon Sep 17 00:00:00 2001
From: veselcraft <veselcraft@icloud.com>
Date: Sun, 12 Nov 2023 23:07:02 +0300
Subject: [PATCH 16/21] VKAPI: add smth idk

---
 VKAPI/Handlers/Users.php | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/VKAPI/Handlers/Users.php b/VKAPI/Handlers/Users.php
index 68bf828f..6eccab1b 100644
--- a/VKAPI/Handlers/Users.php
+++ b/VKAPI/Handlers/Users.php
@@ -165,6 +165,18 @@ final class Users extends VKAPIRequestHandler
 							case "interests":
 								$response[$i]->interests = $usr->getInterests();
 								break;
+							case "quotes":
+								$response[$i]->interests = $usr->getFavoriteQuote();
+								break;
+							case "email":
+								$response[$i]->interests = $usr->getEmail();
+								break;
+							case "telegram":
+								$response[$i]->interests = $usr->getTelegram();
+								break;
+							case "about":
+								$response[$i]->interests = $usr->getDescription();
+								break;
 							case "rating":
 								$response[$i]->rating = $usr->getRating();
 								break;	 

From 626b5b49bb9169e024f9e9584c6c49692c61aab2 Mon Sep 17 00:00:00 2001
From: veselcraft <veselcraft@icloud.com>
Date: Mon, 13 Nov 2023 12:36:46 +0300
Subject: [PATCH 17/21] VKAPI: fix php 8 issue when DELETED appears in empty
 friend list

---
 VKAPI/Handlers/Users.php | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/VKAPI/Handlers/Users.php b/VKAPI/Handlers/Users.php
index 6eccab1b..65b7c191 100644
--- a/VKAPI/Handlers/Users.php
+++ b/VKAPI/Handlers/Users.php
@@ -12,6 +12,9 @@ final class Users extends VKAPIRequestHandler
         $users = new UsersRepo;
 		if($user_ids == "0")
 			$user_ids = (string) $authuser->getId();
+
+		if($user_ids == "")
+			return array();
 		
         $usrs = explode(',', $user_ids);
         $response = array();

From d183b1a8a3df8bb14f490b5473353443b4348ee3 Mon Sep 17 00:00:00 2001
From: veselcraft <veselcraft@icloud.com>
Date: Mon, 13 Nov 2023 12:37:15 +0300
Subject: [PATCH 18/21] compatibility fixxxxxx

---
 VKAPI/Handlers/Friends.php | 6 +++++-
 VKAPI/Handlers/Wall.php    | 2 +-
 2 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/VKAPI/Handlers/Friends.php b/VKAPI/Handlers/Friends.php
index 56de3294..59cf5679 100644
--- a/VKAPI/Handlers/Friends.php
+++ b/VKAPI/Handlers/Friends.php
@@ -4,7 +4,7 @@ use openvk\Web\Models\Repositories\Users as UsersRepo;
 
 final class Friends extends VKAPIRequestHandler
 {
-	function get(int $user_id, string $fields = "", int $offset = 0, int $count = 100): object
+	function get(int $user_id = 0, string $fields = "", int $offset = 0, int $count = 100): object
 	{
 		$i = 0;
 		$offset++;
@@ -14,6 +14,10 @@ final class Friends extends VKAPIRequestHandler
 
 		$this->requireUser();
 
+		if ($user_id == 0) {
+			$user_id = $this->getUser()->getId();
+		}
+
 		if (is_null($users->get($user_id))) {
 			$this->fail(100, "One of the parameters specified was missing or invalid");
 		}
diff --git a/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php
index 693ca3cb..add2159b 100644
--- a/VKAPI/Handlers/Wall.php
+++ b/VKAPI/Handlers/Wall.php
@@ -802,7 +802,7 @@ final class Wall extends VKAPIRequestHandler
         return [
             "type"  => "photo",
             "photo" => [
-                "album_id" => $attachment->getAlbum() ? $attachment->getAlbum()->getId() : NULL,
+                "album_id" => $attachment->getAlbum() ? $attachment->getAlbum()->getId() : 0,
                 "date"     => $attachment->getPublicationTime()->timestamp(),
                 "id"       => $attachment->getVirtualId(),
                 "owner_id" => $attachment->getOwner()->getId(),

From 63702d44d1587d85049cb334bb8564c81e1b9b70 Mon Sep 17 00:00:00 2001
From: lalka2018 <99399973+lalka2016@users.noreply.github.com>
Date: Mon, 13 Nov 2023 15:08:18 +0300
Subject: [PATCH 19/21] Something related with audios
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Теперь аудиозаписи в wall.get,getById,getComments,getComment выглядит нормально
- Теперь при создании плейлиста можно выбрать до тысячи песен
- По идее, название трека теперь нормально обрезается и раскрывается при наведении
- Добавлена проверка на существование коммента в wall.getComment
- Плейлисты теперь не вылетают, если пользователь не залогинен.
---
 VKAPI/Handlers/Wall.php                     | 23 +++++++++++++++++----
 Web/Presenters/AudioPresenter.php           |  8 +++----
 Web/Presenters/templates/Audio/Playlist.xml |  2 +-
 Web/Presenters/templates/Audio/player.xml   |  8 +++----
 Web/static/js/al_music.js                   |  2 +-
 5 files changed, 29 insertions(+), 14 deletions(-)

diff --git a/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php
index add2159b..179d5e0e 100644
--- a/VKAPI/Handlers/Wall.php
+++ b/VKAPI/Handlers/Wall.php
@@ -60,7 +60,10 @@ final class Wall extends VKAPIRequestHandler
                 } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) {
                     $attachments[] = $attachment->toVkApiStruct();
                 } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) {
-                    $attachments[] = $attachment->toVkApiStruct($this->getUser());
+                    $attachments[] = [
+                        "type" => "audio",
+                        "audio" => $attachment->toVkApiStruct($this->getUser()),
+                    ];
                 } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) {
                     $repostAttachments = [];
 
@@ -237,7 +240,10 @@ final class Wall extends VKAPIRequestHandler
                     } else if ($attachment instanceof \openvk\Web\Models\Entities\Note) {
                         $attachments[] = $attachment->toVkApiStruct();
                     } else if ($attachment instanceof \openvk\Web\Models\Entities\Audio) {
-                        $attachments[] = $attachment->toVkApiStruct($this->getUser());
+                        $attachments[] = [
+                            "type" => "audio",
+                            "audio" => $attachment->toVkApiStruct($this->getUser())
+                        ];
                     } else if ($attachment instanceof \openvk\Web\Models\Entities\Post) {
                         $repostAttachments = [];
 
@@ -576,7 +582,10 @@ final class Wall extends VKAPIRequestHandler
                 } elseif($attachment instanceof \openvk\Web\Models\Entities\Note) {
                     $attachments[] = $attachment->toVkApiStruct();
                 } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) {
-                    $attachments[] = $attachment->toVkApiStruct($this->getUser());
+                    $attachments[] = [
+                        "type"  => "audio", 
+                        "audio" => $attachment->toVkApiStruct($this->getUser()),
+                    ];
                 }
             }
 
@@ -636,6 +645,9 @@ final class Wall extends VKAPIRequestHandler
 
         $comment = (new CommentsRepo)->get($comment_id); # один хуй айди всех комментов общий
         
+        if(!$comment || $comment->isDeleted()) 
+            $this->fail(100, "Invalid comment");
+
         $profiles = [];
 
         $attachments = [];
@@ -644,7 +656,10 @@ final class Wall extends VKAPIRequestHandler
             if($attachment instanceof \openvk\Web\Models\Entities\Photo) {
                 $attachments[] = $this->getApiPhoto($attachment);
             } elseif($attachment instanceof \openvk\Web\Models\Entities\Audio) {
-                $attachments[] = $attachment->toVkApiStruct($this->getUser());
+                $attachments[] = [
+                    "type" => "audio",
+                    "audio" => $attachment->toVkApiStruct($this->getUser()),
+                ];
             }
         }
 
diff --git a/Web/Presenters/AudioPresenter.php b/Web/Presenters/AudioPresenter.php
index 3bae710c..8ec17012 100644
--- a/Web/Presenters/AudioPresenter.php
+++ b/Web/Presenters/AudioPresenter.php
@@ -304,7 +304,7 @@ final class AudioPresenter extends OpenVKPresenter
         if ($_SERVER["REQUEST_METHOD"] === "POST") {
             $title = $this->postParam("title");
             $description = $this->postParam("description");
-            $audios = !empty($this->postParam("audios")) ? array_slice(explode(",", $this->postParam("audios")), 0, 100) : [];
+            $audios = !empty($this->postParam("audios")) ? array_slice(explode(",", $this->postParam("audios")), 0, 1000) : [];
 
             if(empty($title) || iconv_strlen($title) < 1)
                 $this->flashFail("err", tr("error"), tr("set_playlist_name"));
@@ -478,9 +478,9 @@ final class AudioPresenter extends OpenVKPresenter
         $this->template->audios = iterator_to_array($playlist->fetch($page, 10));
         $this->template->ownerId = $owner_id;
         $this->template->owner = $playlist->getOwner();
-        $this->template->isBookmarked = $playlist->isBookmarkedBy($this->user->identity);
-        $this->template->isMy = $playlist->getOwner()->getId() === $this->user->id;
-        $this->template->canEdit = $playlist->canBeModifiedBy($this->user->identity);
+        $this->template->isBookmarked = $this->user->identity && $playlist->isBookmarkedBy($this->user->identity);
+        $this->template->isMy = $this->user->identity &&  $playlist->getOwner()->getId() === $this->user->id;
+        $this->template->canEdit = $this->user->identity &&  $playlist->canBeModifiedBy($this->user->identity);
     }
 
     function renderAction(int $audio_id): void
diff --git a/Web/Presenters/templates/Audio/Playlist.xml b/Web/Presenters/templates/Audio/Playlist.xml
index f14b6ac1..175dfc3a 100644
--- a/Web/Presenters/templates/Audio/Playlist.xml
+++ b/Web/Presenters/templates/Audio/Playlist.xml
@@ -39,7 +39,7 @@
                 <img src="{$playlist->getCoverURL('normal')}" alt="{_playlist_cover}">
             </a>
 
-            <div class="profile_links" style="width: 139px;">
+            <div class="profile_links" style="width: 139px;" n:if="isset($thisUser)">
                 <a class="profile_link" style="width: 98%;" href="/playlist{$playlist->getPrettyId()}/edit" n:if="$playlist->canBeModifiedBy($thisUser)">{_edit_playlist}</a>
                 <a class="profile_link" style="width: 98%;" id="bookmarkPlaylist" data-id="{$playlist->getId()}" n:if="!$isBookmarked">{_bookmark}</a>
                 <a class="profile_link" style="width: 98%;" id="unbookmarkPlaylist" data-id="{$playlist->getId()}" n:if="$isBookmarked">{_unbookmark}</a>
diff --git a/Web/Presenters/templates/Audio/player.xml b/Web/Presenters/templates/Audio/player.xml
index fb5862b4..a3bf5ad5 100644
--- a/Web/Presenters/templates/Audio/player.xml
+++ b/Web/Presenters/templates/Audio/player.xml
@@ -11,13 +11,13 @@
             </div>
 
             <div class="status" style="margin-top: 12px;">
-                <div class="mediaInfo" style="margin-bottom: -8px; cursor: pointer;display:flex;width: 85%;">
-                    <div class="info noOverflow">
+                <div class="mediaInfo noOverflow" style="margin-bottom: -8px; cursor: pointer;display:flex;width: 85%;">
+                    <div class="info">
                         <strong class="performer">
-                            <a href="/search?query=&type=audios&sort=id&only_performers=on&query={$audio->getPerformer()}">{ovk_proc_strtr($audio->getPerformer(), 30)}</a>
+                            <a href="/search?query=&type=audios&sort=id&only_performers=on&query={$audio->getPerformer()}">{$audio->getPerformer()}</a>
                         </strong>
                         —
-                        <span class="title {if !empty($audio->getLyrics())}withLyrics{/if}">{ovk_proc_strtr($audio->getTitle(), 30)}</span>
+                        <span class="title {if !empty($audio->getLyrics())}withLyrics{/if}">{$audio->getTitle()}</span>
                     </div>
 
                     <div class="explicitMark" n:if="$audio->isExplicit()"></div>
diff --git a/Web/static/js/al_music.js b/Web/static/js/al_music.js
index 691b6edb..ac449c0d 100644
--- a/Web/static/js/al_music.js
+++ b/Web/static/js/al_music.js
@@ -669,7 +669,7 @@ document.addEventListener("DOMContentLoaded", function() {
             bigPlayerObserver.observe(bigplayer);
     }
 
-    $(`.audioEntry .mediaInfo`).on("mouseover mouseleave", (e) => {
+    $(document).on("mouseover mouseleave", `.audioEntry .mediaInfo`, (e) => {
         const info = e.currentTarget.closest(".mediaInfo")
         const overfl = info.querySelector(".info")
 

From 55f7069193c6830b3a789af33b6af092a8b7ade0 Mon Sep 17 00:00:00 2001
From: zavsc <90477403+zavsc@users.noreply.github.com>
Date: Mon, 13 Nov 2023 16:25:17 +0300
Subject: [PATCH 20/21] Update dependencies (#1027)

---
 composer.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/composer.json b/composer.json
index a248197a..373d667d 100644
--- a/composer.json
+++ b/composer.json
@@ -10,7 +10,7 @@
         "wapmorgan/binary-stream": "dev-master",
         "al/emoji-detector": "dev-master",
         "ezyang/htmlpurifier": "dev-master",
-        "scssphp/scssphp": "dev-master",
+        "scssphp/scssphp": "dev-main",
         "lfkeitel/phptotp": "dev-master",
         "chillerlan/php-qrcode": "dev-main",
         "vearutop/php-obscene-censor-rus": "dev-master",

From c43e68ab708d7b8e0fe5ad80ed30d0ee16981474 Mon Sep 17 00:00:00 2001
From: IsamiRi <53663257+isamirivers@users.noreply.github.com>
Date: Mon, 13 Nov 2023 16:36:33 +0300
Subject: [PATCH 21/21] Russian Latin: Music update (#1025)

---
 locales/ru_lat.strings | 123 ++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 122 insertions(+), 1 deletion(-)

diff --git a/locales/ru_lat.strings b/locales/ru_lat.strings
index 74f79d57..7f1308b7 100644
--- a/locales/ru_lat.strings
+++ b/locales/ru_lat.strings
@@ -151,7 +151,7 @@
 "post_deact_silent_m" = "molča udalil svoju stranicu.";
 "post_deact_silent_f" = "molča udalila svoju stranicu.";
 "post_on_your_wall" = "na vašej stene";
-"post_on_group_wall" = "v $1";
+"post_on_group_wall" = "в $1";
 "post_on_user_wall" = "na stene $1";
 "wall" = "Stena";
 "post" = "Zapisí";
@@ -464,6 +464,7 @@
 "my_videos" = "Moi Videozapisi";
 "my_messages" = "Moi Soobsčenija";
 "my_notes" = "Moi Zametki";
+"my_audios" = "Moi Audiozapisi";
 "my_groups" = "Moi Gruppy";
 "my_feed" = "Moi Novosti";
 "my_feedback" = "Moi Otvety";
@@ -543,6 +544,7 @@
 "privacy_setting_add_to_friends" = "Kto možet nazyvatí menja drugom";
 "privacy_setting_write_wall" = "Kto možet pisatí u menja na stene";
 "privacy_setting_write_messages" = "Kto možet pisatí mne soobsčenija";
+"privacy_setting_view_audio" = "Komu vidno moi audiozapisi";
 "privacy_value_anybody" = "Vse želajusčie";
 "privacy_value_anybody_dative" = "Vsem želajusčim";
 "privacy_value_users" = "Polízovateljam OpenVK";
@@ -675,9 +677,128 @@
 "upload_new_video" = "Zagruzití novoe video";
 "max_attached_videos" = "Maksimum 10 videozapisej";
 "max_attached_photos" = "Maksimum 10 fotografij";
+"max_attached_audios" = "Maksimum 10 audiozapisej";
 "no_videos" = "U vas net video.";
 "no_videos_results" = "Net rezulítatov.";
 
+/* Audios */
+
+"audios" = "Audiozapisi";
+"audio" = "Audiozapisí";
+"playlist" = "Plejlist";
+"upload_audio" = "Zagruzití audio";
+"upload_audio_to_group" = "Zagruzití audio v gruppu";
+
+"performer" = "Ispolnitelí";
+"audio_name" = "Nazvanie";
+"genre" = "Žanr";
+"lyrics" = "Tekst";
+
+"select_another_file" = "Vybratí drugoj fajl";
+
+"limits" = "Ograničenija";
+"select_audio" = "Vyberite audiozapisí na Vašem kompíjutere";
+"audio_requirements" = "Audiozapisí dolžna bytí dlinnoj ot $1c do $2 minut, vesití do $3mb i soderžatí audiopotok.";
+"audio_requirements_2" = "Audiozapisí ne dolžna narušatí avtorskie i smežnye prava.";
+"you_can_also_add_audio_using" = "Vy takže možete dobavití audiozapisí iz čisla uže zagružennyh fajlov, vospolízovavšisí";
+"search_audio_inst" = "poiskom po audio";
+
+"audio_embed_not_found" = "Audiozapisí ne najdena";
+"audio_embed_deleted" = "Audiozapisí byla udalena";
+"audio_embed_withdrawn" = "Audiozapisí byla izjjata po obrasčeniju pravoobladatelja.";
+"audio_embed_forbidden" = "Nastrojki privatnosti polízovatelja ne pozvoljajut vstraivatí etu kompoziciju";
+"audio_embed_processing" = "Audio esčjo obrabatyvaetsja, libo obrabotalosí nepravilíno.";
+
+"audios_count_zero" = "Net audiozapisej";
+"audios_count_one" = "Odna audiozapisí";
+"audios_count_few" = "$1 audiozapisi";
+"audios_count_many" = "$1 audiozapisej";
+"audios_count_other" = "$1 audiozapisej";
+
+"track_unknown" = "Neizvesten";
+"track_noname" = "Bez nazvanija";
+
+"my_music" = "Moja muzyka";
+"music_user" = "Muzyka polízovatelja";
+"music_club" = "Muzyka gruppy";
+"audio_new" = "Novoe";
+"audio_popular" = "Populjarnoe";
+"audio_search" = "Poisk";
+
+"my_audios_small" = "Moi audiozapisi";
+"my_playlists" = "Moi plejlisty";
+"playlists" = "Plejlisty";
+"audios_explicit" = "Soderžit necenzurnuju leksiku";
+"withdrawn" = "Izjjato";
+"deleted" = "Udaleno";
+"owner" = "Vladelec";
+"searchable" = "Dostupno v poiske";
+
+"select_audio" = "Vybratí audiozapisi";
+"no_playlists_thisuser" = "Vy esčjo ne dobavljali plejlistov.";
+"no_playlists_user" = "Etot polízovatelí esčjo ne dobavljal plejlistov.";
+"no_playlists_club" = "Eta gruppa esčjo ne dobavljala plejlistov.";
+
+"no_audios_thisuser" = "Vy esčjo ne dobavljali audiozapisej.";
+"no_audios_user" = "Etot polízovatelí esčjo ne dobavljal audiozapisej.";
+"no_audios_club" = "Eta gruppa esčjo ne dobavljala audiozapisej.";
+
+"new_playlist" = "Novyj plejlist";
+"created_playlist" = "sozdan";
+"updated_playlist" = "obnovljon";
+"bookmark" = "Dobavití v kollekciju";
+"unbookmark" = "Ubratí iz kollekcii";
+"empty_playlist" = "V etom plejliste net audiozapisej.";
+"edit_playlist" = "Redaktirovatí plejlist";
+"unable_to_load_queue" = "Ne udalosí zagruzití očeredí.";
+
+"fully_delete_audio" = "Polnostíju udalití audiozapisí";
+"attach_audio" = "Prikrepití audiozapisí";
+"detach_audio" = "Otkrepití audiozapisí";
+
+"show_more_audios" = "Pokazatí bolíše audiozapisej";
+"add_to_playlist" = "Dobavití v plejlist";
+"remove_from_playlist" = "Udalití iz plejlista";
+"delete_playlist" = "Udalití plejlist";
+"playlist_cover" = "Obložka plejlista";
+"playlists_user" = "Plejlisty políz.";
+"playlists_club" = "Plejlisty gruppy";
+"change_cover" = "Smenití obložku";
+"playlist_cover" = "Obložka plejlista";
+
+"minutes_count_zero" = "dlitsja nolí minut";
+"minutes_count_one" = "dlitsja odnu minutu";
+"minutes_count_few" = "dlitsja $1 minuty";
+"minutes_count_many" = "dlitsja $1 minut";
+"minutes_count_other" = "dlitsja $1 minut";
+
+"listens_count_zero" = "net proslušivanij";
+"listens_count_one" = "odno proslušivanie";
+"listens_count_few" = "$1 proslušivanija";
+"listens_count_many" = "$1 proslušivanij";
+"listens_count_other" = "$1 proslušivanij";
+
+"add_audio_to_club" = "Dobavití audio v gruppu";
+"what_club_add" = "V kakuju gruppu vy hotite dobavití pesnju?";
+"group_has_audio" = "U gruppy uže estí eta pesnja.";
+"group_hasnt_audio" = "U gruppy net etoj pesni.";
+
+"by_name" = "po kompozicijam";
+"by_performer" = "po ispolnitelju";
+"no_access_clubs" = "Net grupp, gde vy javljaetesí administratorom.";
+"audio_successfully_uploaded" = "Audio uspešno zagruženo i na dannyj moment obrabatyvaetsja.";
+
+"broadcast_audio" = "Translirovatí audio v status";
+"sure_delete_playlist" = "Vy dejstvitelíno hotite udalití etot plejlist?";
+"edit_audio" = "Redaktirovatí audiozapisí";
+"audios_group" = "Audiozapisi gruppy";
+"playlists_group" = "Plejlisty gruppy";
+
+"play_tip" = "Proigryvanie/pauza";
+"repeat_tip" = "Povtorenie";
+"shuffle_tip" = "Peremešatí";
+"mute_tip" = "Zaglušití";
+
 /* Notifications */
 
 "feedback" = "Otvety";