diff --git a/VKAPI/Handlers/Audio.php b/VKAPI/Handlers/Audio.php
index 9fc4a535..f94de810 100644
--- a/VKAPI/Handlers/Audio.php
+++ b/VKAPI/Handlers/Audio.php
@@ -1,22 +1,491 @@
 <?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\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 || !$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");
+        }
+    }
+
+    function getById(string $audios, ?string $hash = NULL, int $need_user = 0): object
+    {
+        $this->requireUser();
+
+        $audioIds = array_unique(explode(",", $audios));
+        if(sizeof($audioIds) === 1) {
+            $descriptor = explode("_", $audioIds[0]);
+            if(sizeof($descriptor) === 1)
+                $audio = (new Audios)->get((int) $descriptor[0]);
+            else if(sizeof($descriptor) === 2)
+                $audio = (new Audios)->getByOwnerAndVID((int) $descriptor[0], (int) $descriptor[1]);
+            else
+                $this->fail(8, "Invalid audio $descriptor");
+
+            return (object) [
+                "count" => 1,
+                "items" => [
+                    $this->toSafeAudioStruct($audio, $hash, (bool) $need_user),
+                ],
+            ];
+        } else if(sizeof($audioIds) > 32) {
+            $this->fail(1980, "Can't get more than 32 audios at once");
+        }
+
+        $audios = [];
+        foreach($audioIds as $id)
+            $audios[] = $this->getById($id, $hash)->items[0];
+
+        return (object) [
+            "count" => sizeof($audios),
+            "items" => $audios,
+        ];
+    }
+
+    // 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
+    {
+        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($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 = NULL, 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();
+
+        if($album_id != 0)
+            $this->fail(10, "album_id is not supported");
+
+        $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(!is_null($audio_ids)) {
+            $audio_ids = explode(",", $audio_ids);
+            if(!$audio_ids)
+                $this->fail(10, "Audio::get@L0d186:explode(string): Unknown error");
+
+            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");
+
+            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  = [];
+        $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();
+
+        $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($group)) {
+            $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($owner, $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->getId(), $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");
+
+        $dbContext = DatabaseConnection::i()->getContext();
+        $entityIds = [];
+        $query     = $dbContext->table("subscriptions")->select("model, target")
+            ->where("follower", $this->getUser()->getId());
+
+        if($filter != "all")
+            $query = $query->where("model = ?", "openvk\\Web\\Models\\Entities\\" . ($filter == "groups" ? "Club" : "User"));
+
+        foreach($query as $_rel) {
+            $id = $_rel->target;
+            if($_rel->model === "openvk\\Web\\Models\\Entities\\Club")
+                $id *= -1;
+
+            $entityIds[] = $id;
+        }
+
+        $audioIds  = [];
+        $threshold = $active === 0 ? 3600 : 120;
+        foreach($entityIds as $ent) {
+            $lastListen = $dbContext->table("audio_listens")->where("entity", $ent)
+                ->where("time >= ?", time() - $threshold)->fetch();
+            if(!$lastListen)
+                continue;
+
+            $audio          = (new Audios)->get($lastListen->audio);
+            $audioIds[$ent] = $this->toSafeAudioStruct($audio, $hash);
+        }
+
+        $items = [];
+        foreach($audioIds as $ent => $audio) {
+            $entity = ($ent < 0 ? (new Groups($this->getUser())) : (new Users($this->getUser())))
+                ->get((string) abs($ent));
+
+            $entity->status_audio = $audio;
+        }
+
+        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();
+
+        $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->save();
+
+        return $lyrics;
+    }
+
+    function add(int $audio_id, int $owner_id, ?int $group_id = NULL, ?int $album_id = NULL): string
+    {
+        $this->requireUser();
+
+        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)");
+
+        $audio->add($to);
+
+        return $audio->getPrettyId();
+    }
+
+    function delete(int $audio_id, int $owner_id, ?int $group_id = NULL): int
+    {
+        $this->requireUser();
+
+        $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];
+    }
 }
diff --git a/Web/Models/Entities/Audio.php b/Web/Models/Entities/Audio.php
index db87c6f3..f758692d 100644
--- a/Web/Models/Entities/Audio.php
+++ b/Web/Models/Entities/Audio.php
@@ -20,6 +20,35 @@ class Audio extends Media
         '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"))
@@ -118,6 +147,11 @@ class Audio extends Media
         return $this->getTitle() . " - " . $this->getPerformer();
     }
 
+    function getGenre(): ?string
+    {
+        return $this->getRecord()->genre;
+    }
+
     function getLyrics(): ?string
     {
         return $this->getRecord()->lyrics ?? NULL;
@@ -153,17 +187,16 @@ class Audio extends Media
 
     function getOriginalURL(bool $force = false): string
     {
-        $disallowed = OPENVK_ROOT_CONF["openvk"]["preferences"]["music"]["exposeOriginalURLs"] && !$force;
+        $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);
-        $garbage = sha1((string) time());
+        $key = bin2hex($this->getRecord()->token);
 
-        return str_replace(".mpd", "_fragments", $this->getURL()) . "/original_$key.mp3?tk=$garbage";
+        return str_replace(".mpd", "_fragments", $this->getURL()) . "/original_$key.mp3";
     }
 
     function getKeys(): array
@@ -173,6 +206,11 @@ class Audio extends Media
         return $keys;
     }
 
+    function isAnonymous(): bool
+    {
+        return false;
+    }
+
     function isExplicit(): bool
     {
         return (bool) $this->getRecord()->explicit;
@@ -249,21 +287,29 @@ class Audio extends Media
         return true;
     }
 
-    function listen(User $user): bool
+    function listen($entity): bool
     {
+        $entityId = $entity->getId();
+        if($entity instanceof Club)
+            $entityId *= -1;
+
         $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens");
         $lastListen   = $listensTable->where([
-            "user"  => $user->getId(),
-            "audio" => $this->getId(),
+            "entity" => $entityId,
+            "audio"  => $this->getId(),
         ])->fetch();
 
-        if(!$lastListen || (time() - $lastListen->time >= 900)) {
+        if(!$lastListen || (time() - $lastListen->time >= $this->getLength())) {
             $listensTable->insert([
-                "user"  => $user->getId(),
-                "audio" => $this->getId(),
-                "time"  => time(),
+                "entity" => $entityId,
+                "audio"  => $this->getId(),
+                "time"   => time(),
             ]);
-            $this->stateChanges("listens", $this->getListens() + 1);
+
+            if($entity instanceof User) {
+                $this->stateChanges("listens", $this->getListens() + 1);
+                $this->save();
+            }
 
             return true;
         }
@@ -275,10 +321,84 @@ class Audio extends Media
         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);
@@ -311,4 +431,15 @@ class Audio extends Media
     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();
+
+        parent::delete($softly);
+    }
 }
\ No newline at end of file
diff --git a/Web/Models/Repositories/Audios.php b/Web/Models/Repositories/Audios.php
index 994b3e55..f2fa0062 100644
--- a/Web/Models/Repositories/Audios.php
+++ b/Web/Models/Repositories/Audios.php
@@ -15,6 +15,10 @@ class Audios
     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();
@@ -42,10 +46,10 @@ class Audios
         return new Audio($audio);
     }
 
-    private function getByEntityID(int $entity, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable
+    function getByEntityID(int $entity, int $offset = 0, ?int $limit = NULL, ?int& $deleted = nullptr): \Traversable
     {
         $perPage ??= OPENVK_DEFAULT_PER_PAGE;
-        $iter = $this->rels->where("entity", $entity)->page($page, $perPage);
+        $iter = $this->rels->where("entity", $entity)->limit($limit ?? OPENVK_DEFAULT_PER_PAGE, $offset);
         foreach($iter as $rel) {
             $audio = $this->get($rel->audio);
             if(!$audio || $audio->isDeleted()) {
@@ -59,12 +63,12 @@ class Audios
 
     function getByUser(User $user, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable
     {
-        return $this->getByEntityID($user->getId(), $page, $perPage, $deleted);
+        return $this->getByEntityID($user->getId(), ($perPage * ($page - 1)), $perPage, $deleted);
     }
 
     function getByClub(Club $club, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable
     {
-        return $this->getByEntityID($club->getId() * -1, $page, $perPage, $deleted);
+        return $this->getByEntityID($club->getId() * -1, ($perPage * ($page - 1)), $perPage, $deleted);
     }
 
     function getUserCollectionSize(User $user): int
@@ -87,7 +91,7 @@ class Audios
         return new EntityStream("Audio", $search);
     }
 
-    function getGlobal(int $order): EntityStream
+    function getGlobal(int $order, ?string $genreId = NULL): EntityStream
     {
         $search = $this->audios->where([
             "deleted"   => 0,
@@ -95,15 +99,24 @@ class Audios
             "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): EntityStream
+    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 (performer, name) AGAINST (? WITH QUERY EXPANSION)", $query);
+        ])->where("MATCH ($columns) AGAINST (? WITH QUERY EXPANSION)", $query)->order($order);
+
+        if($withLyrics)
+            $search = $search->where("lyrics IS NOT NULL");
 
         return new EntityStream("Audio", $search);
     }
diff --git a/bootstrap.php b/bootstrap.php
index 70b52244..8adcbb94 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -62,6 +62,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(Traversable $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"] ?? []);