From ee56859acd63458e3380e225c206db6ec9b3e6fa Mon Sep 17 00:00:00 2001 From: Celestora Date: Tue, 22 Mar 2022 13:42:06 +0200 Subject: [PATCH] Add audio upload feature --- Web/Models/Entities/Audio.php | 301 ++++++++++++++++++++++ Web/Models/Entities/Traits/TOwnable.php | 6 + Web/Models/Entities/Video.php | 2 +- Web/Models/Repositories/Audios.php | 110 ++++++++ Web/Models/shell/processAudio.ps1 | 39 +++ Web/Presenters/AudioPresenter.php | 149 +++++++++++ Web/Presenters/BlobPresenter.php | 4 +- Web/Presenters/templates/Audio/Upload.xml | 104 ++++++++ Web/di.yml | 2 + Web/routes.yml | 14 + Web/static/css/style.css | 121 +++++++++ composer.json | 3 +- locales/ru.strings | 25 ++ openvk-example.yml | 2 + 14 files changed, 879 insertions(+), 3 deletions(-) create mode 100644 Web/Models/Entities/Audio.php create mode 100644 Web/Models/Repositories/Audios.php create mode 100644 Web/Models/shell/processAudio.ps1 create mode 100644 Web/Presenters/AudioPresenter.php create mode 100644 Web/Presenters/templates/Audio/Upload.xml diff --git a/Web/Models/Entities/Audio.php b/Web/Models/Entities/Audio.php new file mode 100644 index 00000000..8bd9ccf6 --- /dev/null +++ b/Web/Models/Entities/Audio.php @@ -0,0 +1,301 @@ +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); + if(!empty($vstreams) && !ctype_space($vstreams)) + 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", $ss); + + try { + $args = [ + OPENVK_ROOT, + $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)->start(); + + # Wait until processAudio will consume the file + $start = time(); + while(file_exists($filename)) + if(time() - $start > 5) + exit("Timed out waiting for 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->getTitle() . " - " . $this->getPerformer(); + } + + function getLyrics(): ?string + { + return $this->getRecord()->lyrics ?? NULL; + } + + function getLength(): int + { + return $this->getRecord()->length; + } + + 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); + $garbage = sha1((string) time()); + + return str_replace(".mpd", "_fragments", $this->getURL()) . "/original_$key.mp3?tk=$garbage"; + } + + function getKeys(): array + { + $keys[bin2hex($this->getRecord()->kid)] = bin2hex($this->getRecord()->key); + + return $keys; + } + + function isExplicit(): bool + { + return $this->getRecord()->explicit; + } + + function isWithdrawn(): bool + { + return $this->getRecord()->withdrawn; + } + + function isUnlisted(): bool + { + return $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; + + DatabaseConnection::i()->getContext()->table("audio_relations")->insert([ + "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1), + "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(User $user): bool + { + $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens"); + $lastListen = $listensTable->where([ + "user" => $user->getId(), + "audio" => $this->getId(), + ])->fetch(); + + if(!$lastListen || (time() - $lastListen->time >= 900)) { + $listensTable->insert([ + "user" => $user->getId(), + "audio" => $this->getId(), + "time" => time(), + ]); + $this->stateChanges("listens", $this->getListens() + 1); + + return true; + } + + $lastListen->update([ + "time" => time(), + ]); + + return false; + } + + function setGenre(string $genre): void + { + if(!in_array($genre, Audio::genres)) { + $this->stateChanges("genre", NULL); + } + + $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."); + } +} \ No newline at end of file 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/Video.php b/Web/Models/Entities/Video.php index f57159b7..49c6ed42 100644 --- a/Web/Models/Entities/Video.php +++ b/Web/Models/Entities/Video.php @@ -1,7 +1,7 @@ context = DatabaseConnection::i()->getContext(); + $this->audios = $this->context->table("audios"); + $this->rels = $this->context->table("audio_relations"); + } + + function get(int $id): ?Audio + { + $audio = $this->audios->get($id); + if(!$audio) + return NULL; + + return new Audio($audio); + } + + 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); + } + + private function getByEntityID(int $entity, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + $perPage ??= OPENVK_DEFAULT_PER_PAGE; + $iter = $this->rels->where("entity", $entity)->page($page, $perPage); + foreach($iter as $rel) { + $audio = $this->get($rel->audio); + if(!$audio || $audio->isDeleted()) { + $deleted++; + continue; + } + + yield $audio; + } + } + + function getByUser(User $user, int $page = 1, ?int $perPage = NULL, ?int& $deleted = nullptr): \Traversable + { + return $this->getByEntityID($user->getId(), $page, $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); + } + + 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 getByUploader(User $user): EntityStream + { + $search = $this->audios->where([ + "owner" => $user->getId(), + "deleted" => 0, + ]); + + return new EntityStream("Audio", $search); + } + + function getGlobal(int $order): EntityStream + { + $search = $this->audios->where([ + "deleted" => 0, + "unlisted" => 0, + "withdrawn" => 0, + ])->order($order == Audios::ORDER_NEW ? "created DESC" : "listens DESC"); + + return new EntityStream("Audio", $search); + } + + function search(string $query): EntityStream + { + $search = $this->audios->where([ + "unlisted" => 0, + "deleted" => 0, + ])->where("MATCH (performer, name) AGAINST (? WITH QUERY EXPANSION)", $query); + + return new EntityStream("Audio", $search); + } +} \ No newline at end of file diff --git a/Web/Models/shell/processAudio.ps1 b/Web/Models/shell/processAudio.ps1 new file mode 100644 index 00000000..7bc1ab32 --- /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 -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') + +Move-Item -Path ($fileHash + '_fragments') -Destination "$storageDir/$hashPart" +Move-Item -Path ("$fileHash.mpd") -Destination "$storageDir/$hashPart" + +cd .. +Remove-Item -Recurse $temp +Remove-Item $audioFile \ No newline at end of file diff --git a/Web/Presenters/AudioPresenter.php b/Web/Presenters/AudioPresenter.php new file mode 100644 index 00000000..f1371c9b --- /dev/null +++ b/Web/Presenters/AudioPresenter.php @@ -0,0 +1,149 @@ +audios = $audios; + } + + private function renderApp(string $playlistHandle): void + { + $this->assertUserLoggedIn(); + + $this->template->_template = "Audio/Player"; + $this->template->handle = $playlistHandle; + } + + function renderPopular(): void + { + $this->renderApp("_popular"); + } + + function renderNew(): void + { + $this->renderApp("_new"); + } + + function renderList(int $owner): void + { + $entity = NULL; + if($owner < 0) + $entity = (new Clubs)->get($owner); + else + $entity = (new Users)->get($owner); + + if(!$entity) + $this->notFound(); + + $this->renderApp("owner=$owner"); + } + + function renderView(int $owner, int $id): void + { + $this->assertUserLoggedIn(); + + $audio = $this->audios->getByOwnerAndVID($owner, $id); + if(!$audio || $audio->isDeleted()) + $this->notFound(); + + if(!$audio->canBeViewedBy($this->user->identity)) + $this->flashFail("err", tr("forbidden"), tr("forbidden_comment")); + + $this->renderApp("id=" . $audio->getId()); + } + + function renderEmbed(int $owner, int $id): void + { + $audio = $this->audios->getByOwnerAndVID($owner, $id); + if(!$audio) { + header("HTTP/1.1 404 Not Found"); + exit("" . tr("audio_embed_not_found") . "."); + } else if($audio->isDeleted()) { + header("HTTP/1.1 410 Not Found"); + exit("" . tr("audio_embed_deleted") . "."); + } else if($audio->isWithdrawn()) { + header("HTTP/1.1 451 Unavailable for legal reasons"); + exit("" . tr("audio_embed_withdrawn") . "."); + } else if(!$audio->canBeViewedBy(NULL)) { + header("HTTP/1.1 403 Forbidden"); + exit("" . tr("audio_embed_forbidden") . "."); + } else if(!$audio->isAvailable()) { + header("HTTP/1.1 425 Too Early"); + exit("" . tr("audio_embed_processing") . "."); + } + + $this->template->audio = $audio; + } + + function renderUpload(): void + { + $this->assertUserLoggedIn(); + + $group = NULL; + 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")); + + // TODO check if group allows uploads to anyone + if(!$group->canBeModifiedBy($this->user->identity)) + $this->flashFail("err", tr("forbidden"), tr("not_enough_permissions_comment")); + } + + $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")); + } else { + $err = !isset($upload) ? 65536 : $upload["error"]; + $err = str_pad(dechex($err), 9, "0", STR_PAD_LEFT); + $this->flashFail("err", tr("error"), tr("error_generic") . "Upload error: 0x$err"); + } + + $performer = $this->postParam("performer"); + $name = $this->postParam("name"); + $lyrics = $this->postParam("lyrics"); + $genre = empty($this->postParam("genre")) ? "undefined" : $this->postParam("genre"); + $nsfw = ($this->postParam("nsfw") ?? "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")); + + $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."); + } + + $audio->save(); + $audio->add($group ?? $this->user->identity); + + $this->redirect(is_null($group) ? "/audios" . $this->user->id : "/audios-" . $group->getId()); + } +} \ No newline at end of file diff --git a/Web/Presenters/BlobPresenter.php b/Web/Presenters/BlobPresenter.php index 117eebca..965cfdcc 100644 --- a/Web/Presenters/BlobPresenter.php +++ b/Web/Presenters/BlobPresenter.php @@ -16,6 +16,8 @@ final class BlobPresenter extends OpenVKPresenter function renderFile(/*string*/ $dir, string $name, string $format) { + header("Access-Control-Allow-Origin: *"); + $dir = $this->getDirName($dir); $name = preg_replace("%[^a-zA-Z0-9_\-]++%", "", $name); $path = OPENVK_ROOT . "/storage/$dir/$name.$format"; @@ -24,7 +26,7 @@ final class BlobPresenter extends OpenVKPresenter } else { if(isset($_SERVER["HTTP_IF_NONE_MATCH"])) exit(header("HTTP/1.1 304 Not Modified")); - + header("Content-Type: " . mime_content_type($path)); header("Content-Size: " . filesize($path)); header("ETag: W/\"" . hash_file("snefru", $path) . "\""); diff --git a/Web/Presenters/templates/Audio/Upload.xml b/Web/Presenters/templates/Audio/Upload.xml new file mode 100644 index 00000000..748f63bb --- /dev/null +++ b/Web/Presenters/templates/Audio/Upload.xml @@ -0,0 +1,104 @@ +{extends "../@layout.xml"} + +{block title} + {_upload_audio} +{/block} + +{block header} + {if !is_null($group)} + {$group->getCanonicalName()} + » + {_audios} + {else} + {$thisUser->getCanonicalName()} + » + {_audios} + {/if} + + » + {_upload_audio} +{/block} + +{block content} +
+
+

{_select_audio}


+ {_limits} +
    +
  • {tr("audio_requirements", 1, 30, 25)}
  • +
+
+
+ + + + + + + +
+

+ + {_you_can_also_add_audio_using} {_search_audio_inst}. +
+
+ + + + +{/block} diff --git a/Web/di.yml b/Web/di.yml index 1e47bbc1..a8959d6c 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 @@ -28,6 +29,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 bd682782..7b2ced1e 100644 --- a/Web/routes.yml +++ b/Web/routes.yml @@ -163,6 +163,20 @@ routes: handler: "Videos->edit" - url: "/video{num}_{num}/remove" handler: "Videos->remove" + - url: "/player" + handler: "Audio->app" + - 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}" + handler: "Audio->view" + - url: "/audio{num}_{num}/embed.xhtml" + handler: "Audio->embed" - url: "/{?!club}{num}" handler: "Group->view" placeholders: diff --git a/Web/static/css/style.css b/Web/static/css/style.css index 4c0d3757..5d470284 100644 --- a/Web/static/css/style.css +++ b/Web/static/css/style.css @@ -1977,3 +1977,124 @@ table td[width="120"] { .center { 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: 0px 0px 4px 0px; + margin: 0px; +} + +#audio_upload { + width: 350px; + margin: 20px auto; + margin-bottom: 20px; + margin-bottom: 10px; + padding: 15px 0px; + border: 2px solid #ccc; + background-color: #EFEFEF; + text-align: center; +} + +ul { + list-style: url(http://vkontakte.ru/images/bullet.gif) outside; + margin: 10px 0px; + padding-left: 30px; + color: black; +} + +li { + padding: 1px 0px; +} + +#upload_container ul { + padding-left: 15px; +} + +.audio_list { + margin: 5px 20px; +} + +.audio_row { + width: 100%; + margin-bottom: 10px; +} + +.audio_row .play_button { + height: 16px; + width: 16px; + background: url(http://web.archive.org/web/20101203010619im_/http://vkontakte.ru/images/play.gif); + display: inline-block; + margin-right: 5px; + vertical-align: top; +} + +.audio_row .info { + display: inline-block; + vertical-align: top; + margin-top: 1px; + width: 514px; +} + +.audio_row .duration { + display: inline-block; + vertical-align: top; + color: #777; + font-size: 10px; + margin-left: 5px; + margin-top: 2px; +} + +.audio_row .lines { + height: 5px; + margin-top: 15px; + font-size: 0; +} + +.audio_row .lines .dotted { + border-top: dashed 1px #d8dfea; +} + +.audio_row .active.volume_line { + width: 50px; + margin-left: 20px; +} + +.audio_row .volume_line .dot { + width: 10px; +} + +.audio_row .active { + border-top: 1px solid #5f7d9d; + display: inline-block; + margin-bottom: 5px; +} + +.audio_row .active.duraton_line .dot { + width: 15px; +} + +.audio_row .dot { + height: 5px; + background: #5f7d9d; +} + +.audio_row .active.duraton_line { + width: calc(100% - 70px); +} + +.audio_row .add_button { + background: url(https://vkontakte.ru/images/plus.gif) no-repeat center; + height: 16px; + width: 16px; + display: inline-block; + margin-left: -16px; +} \ No newline at end of file diff --git a/composer.json b/composer.json index 7cfa85e7..0d7f5a70 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "chillerlan/php-qrcode": "dev-main", "vearutop/php-obscene-censor-rus": "dev-master", "erusev/parsedown": "dev-master", - "bhaktaraz/php-rss-generator": "dev-master" + "bhaktaraz/php-rss-generator": "dev-master", + "ext-openssl": "*" }, "minimum-stability": "dev" } diff --git a/locales/ru.strings b/locales/ru.strings index c3c22624..897f7615 100644 --- a/locales/ru.strings +++ b/locales/ru.strings @@ -545,6 +545,29 @@ "view_video" = "Просмотр"; +/* Audios */ + +"audios" = "Аудиозаписи"; +"audio" = "Аудиозапись"; +"upload_audio" = "Загрузить аудио"; + +"performer" = "Исполнитель"; +"audio_name" = "Имя композиции"; +"genre" = "Жанр"; +"lyrics" = "Текст"; + +"limits" = "Ограничения"; +"select_audio" = "Выберите аудиозапись на Вашем компьютере"; +"audio_requirements" = "Аудиозапись должна быть длинной от $1c до $2 минут, весить до $3мб, содержать аудиопоток и не нарушать авторские права."; +"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" = "Композиция ещё обрабатывается"; + /* Notifications */ "feedback" = "Ответы"; @@ -807,6 +830,7 @@ "no_data_description" = "Тут ничего нет... Пока..."; "error" = "Ошибка"; +"error_generic" = "Произошла ошибка общего характера: "; "error_shorturl" = "Данный короткий адрес уже занят."; "error_segmentation" = "Ошибка сегментации"; "error_upload_failed" = "Не удалось загрузить фото"; @@ -814,6 +838,7 @@ "error_new_password" = "Новые пароли не совпадает"; "error_shorturl_incorrect" = "Короткий адрес имеет некорректный формат."; "error_repost_fail" = "Не удалось поделиться записью"; +"error_insufficient_info" = "Вы не указали необходимую информацию."; "forbidden" = "Ошибка доступа"; "forbidden_comment" = "Настройки приватности этого пользователя не разрешают вам смотреть на его страницу."; diff --git a/openvk-example.yml b/openvk-example.yml index df9c55ef..d8494931 100644 --- a/openvk-example.yml +++ b/openvk-example.yml @@ -37,6 +37,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: