Add audio upload feature

This commit is contained in:
Celestora 2022-03-22 13:42:06 +02:00
parent edccb34c53
commit ee56859acd
14 changed files with 879 additions and 3 deletions

View file

@ -0,0 +1,301 @@
<?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'
];
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);
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.");
}
}

View file

@ -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"))

View file

@ -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;

View file

@ -0,0 +1,110 @@
<?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\User;
use openvk\Web\Models\Repositories\Util\EntityStream;
class Audios
{
private $context;
private $audios;
private $rels;
const ORDER_NEW = 0;
const ORDER_POPULAR = 1;
function __construct()
{
$this->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);
}
}

View file

@ -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

View file

@ -0,0 +1,149 @@
<?php declare(strict_types=1);
namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\Audio;
use openvk\Web\Models\Entities\Club;
use openvk\Web\Models\Entities\User;
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;
const MAX_AUDIO_SIZE = 25000000;
function __construct(Audios $audios)
{
$this->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("<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;
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());
}
}

View file

@ -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) . "\"");

View file

@ -0,0 +1,104 @@
{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="background: white; border: 0;">
<div id="upload_container">
<h4>{_select_audio}</h4><br/>
<b><a href="javascript:false">{_limits}</a></b>
<ul>
<li>{tr("audio_requirements", 1, 30, 25)}</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="hash" value="{$csrfToken}" />
<input id="audio_input" type="file" name="blob" accept="audio/*" />
</form>
</div><br/>
<span>{_you_can_also_add_audio_using} <b><a href="/player">{_search_audio_inst}</a></b>.<span>
</div>
</div>
<div id="dialogBoxHtml" style="display: none;">
<table cellspacing="7" cellpadding="0" border="0" align="center">
<tbody>
<tr>
<td width="120" valign="top"><span class="nobold">Имя:</span></td>
<td><input type="text" name="name" autocomplete="off" /></td>
</tr>
<tr>
<td width="120" valign="top"><span class="nobold">Исполнитель:</span></td>
<td><input name="performer" autocomplete="off" /></td>
</tr>
<tr>
<td width="120" valign="top"><span class="nobold">Жанр:</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">Текст:</span></td>
<td><textarea name="lyrics"></textarea></td>
</tr>
</tbody>
</table>
</div>
<script>
u("#audio_input").on("change", function(e) {
if(e.currentTarget.files.length <= 0)
return;
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]");
MessageBox({_upload_audio}, document.querySelector("#dialogBoxHtml").innerHTML, [{_ok}, {_cancel}], [
function() {
var name = u("input[name=name]", this.$dialog().nodes[0]).nodes[0].value;
var perf = u("input[name=performer]", this.$dialog().nodes[0]).nodes[0].value;
var genre = u("select[name=genre]", this.$dialog().nodes[0]).nodes[0].value;
var lyrics = u("textarea[name=lyrics]", this.$dialog().nodes[0]).nodes[0].value;
name_.value = name;
perf_.value = perf;
genre_.value = genre;
lyrics_.value = lyrics;
document.querySelector("#audio_upload > form").submit();
},
Function.noop
]);
});
</script>
{/block}

View file

@ -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

View file

@ -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:

View file

@ -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;
}

View file

@ -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"
}

View file

@ -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" = "Настройки приватности этого пользователя не разрешают вам смотреть на его страницу.";

View file

@ -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: