mirror of
https://github.com/openvk/openvk
synced 2025-02-02 21:15:42 +03:00
Alexander Minkin
6ec54a379d
* feat(lint): add php-cs-fixer for linting Removing previous CODE_STYLE as it was not enforced anyway and using PER-CS 2.0. This is not the reformatting commit. * style: format code according to PER-CS 2.0 with php-cs-fixer * ci(actions): add lint action Resolves #1132.
508 lines
18 KiB
PHP
508 lines
18 KiB
PHP
<?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
|
|
public const genres = [
|
|
'A Cappella', 'Abstract', 'Acid', 'Acid Jazz', 'Acid Punk', 'Acoustic', 'AlternRock', 'Alternative', 'Ambient', 'Anime', 'Art Rock', 'Audio Theatre', 'Audiobook', 'Avantgarde', 'Ballad', 'Baroque', 'Bass', 'Beat', 'Bebob', 'Bhangra', 'Big Band', 'Big Beat', 'Black Metal', 'Bluegrass', 'Blues', 'Booty Bass', 'Breakbeat', 'BritPop', 'Cabaret', 'Celtic', 'Chamber Music', 'Chanson', 'Chillout', 'Chorus', 'Christian Gangsta Rap', 'Christian Rap', 'Christian Rock', 'Classic Rock', 'Classical', 'Club', 'Club-House', 'Comedy', 'Contemporary Christian', 'Country', 'Crossover', 'Cult', 'Dance', 'Dance Hall', 'Darkwave', 'Death Metal', 'Disco', 'Downtempo', 'Dream', 'Drum & Bass', 'Drum Solo', 'Dub', 'Dubstep', 'Duet', 'EBM', 'Easy Listening', 'Eclectic', 'Electro', 'Electroclash', 'Electronic', 'Emo', 'Ethnic', 'Euro-House', 'Euro-Techno', 'Eurodance', 'Experimental', 'Fast Fusion', 'Folk', 'Folk-Rock', 'Folklore', 'Freestyle', 'Funk', 'Fusion', 'G-Funk', 'Game', 'Gangsta Rap', 'Garage', 'Garage Rock', 'Global', 'Goa', 'Gospel', 'Gothic', 'Gothic Rock', 'Grunge', 'Hard Rock', 'Hardcore', 'Heavy Metal', 'Hip-Hop', 'House', 'Humour', 'IDM', 'Illbient', 'Indie', 'Indie Rock', 'Industrial', 'Industro-Goth', 'Instrumental', 'Instrumental Pop', 'Instrumental Rock', 'JPop', 'Jam Band', 'Jazz', 'Jazz+Funk', 'Jungle', 'Krautrock', 'Latin', 'Leftfield', 'Lo-Fi', 'Lounge', 'Math Rock', 'Meditative', 'Merengue', 'Metal', 'Musical', 'National Folk', 'Native American', 'Negerpunk', 'Neoclassical', 'Neue Deutsche Welle', 'New Age', 'New Romantic', 'New Wave', 'Noise', 'Nu-Breakz', 'Oldies', 'Opera', 'Other', 'Podcast', 'Polka', 'Polsk Punk', 'Pop', 'Pop / Funk', 'Pop-Folk', 'Porn Groove', 'Post-Punk', 'Post-Rock', 'Power Ballad', 'Pranks', 'Primus', 'Progressive Rock', 'Psybient', 'Psychedelic', 'Psychedelic Rock', 'Psychobilly', 'Psytrance', 'Punk', 'Punk Rock', 'R&B', 'Rap', 'Rave', 'Reggae', 'Retro', 'Revival', 'Rhythmic Soul', 'Rock', 'Rock & Roll', 'Salsa', 'Samba', 'Satire', 'Shoegaze', 'Showtunes', 'Ska', 'Slow Jam', 'Slow Rock', 'Sonata', 'Soul', 'Sound Clip', 'Soundtrack', 'Southern Rock', 'Space', 'Space Rock', 'Speech', 'Swing', 'Symphonic Rock', 'Symphony', 'Synthpop', 'Tango', 'Techno', 'Techno-Industrial', 'Terror', 'Thrash Metal', 'Top 40', 'Touhou', 'Trailer', 'Trance', 'Tribal', 'Trip-Hop', 'Trop Rock', 'Vocal', 'World Music',
|
|
];
|
|
|
|
# Taken from: https://web.archive.org/web/20220322153107/https://dev.vk.com/reference/objects/audio-genres
|
|
public 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");
|
|
} elseif (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;
|
|
}
|
|
|
|
public function getTitle(): string
|
|
{
|
|
return $this->getRecord()->name;
|
|
}
|
|
|
|
public function getPerformer(): string
|
|
{
|
|
return $this->getRecord()->performer;
|
|
}
|
|
|
|
public function getPerformers(): array
|
|
{
|
|
return explode(", ", $this->getRecord()->performer);
|
|
}
|
|
|
|
public function getName(): string
|
|
{
|
|
return $this->getPerformer() . " — " . $this->getTitle();
|
|
}
|
|
|
|
public function getDownloadName(): string
|
|
{
|
|
return preg_replace('/[\\/:*?"<>|]/', '_', str_replace(' ', '_', $this->getName()));
|
|
}
|
|
|
|
public function getGenre(): ?string
|
|
{
|
|
return $this->getRecord()->genre;
|
|
}
|
|
|
|
public function getLyrics(): ?string
|
|
{
|
|
return !is_null($this->getRecord()->lyrics) ? htmlspecialchars($this->getRecord()->lyrics, ENT_DISALLOWED | ENT_XHTML) : null;
|
|
}
|
|
|
|
public function getLength(): int
|
|
{
|
|
return $this->getRecord()->length;
|
|
}
|
|
|
|
public 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)
|
|
);
|
|
}
|
|
|
|
public function getSegmentSize(): float
|
|
{
|
|
return $this->getRecord()->segment_size;
|
|
}
|
|
|
|
public function getListens(): int
|
|
{
|
|
return $this->getRecord()->listens;
|
|
}
|
|
|
|
public 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";
|
|
}
|
|
|
|
public function getURL(?bool $force = false): string
|
|
{
|
|
if ($this->isWithdrawn()) {
|
|
return "";
|
|
}
|
|
|
|
return parent::getURL();
|
|
}
|
|
|
|
public function getKeys(): array
|
|
{
|
|
$keys[bin2hex($this->getRecord()->kid)] = bin2hex($this->getRecord()->key);
|
|
|
|
return $keys;
|
|
}
|
|
|
|
public function isAnonymous(): bool
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public function isExplicit(): bool
|
|
{
|
|
return (bool) $this->getRecord()->explicit;
|
|
}
|
|
|
|
public function isWithdrawn(): bool
|
|
{
|
|
return (bool) $this->getRecord()->withdrawn;
|
|
}
|
|
|
|
public function isUnlisted(): bool
|
|
{
|
|
return (bool) $this->getRecord()->unlisted;
|
|
}
|
|
|
|
# NOTICE may flush model to DB if it was just processed
|
|
public 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;
|
|
}
|
|
|
|
public 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;
|
|
}
|
|
|
|
public 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;
|
|
}
|
|
|
|
public 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;
|
|
}
|
|
|
|
public 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
|
|
*/
|
|
public 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;
|
|
}
|
|
|
|
public 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);
|
|
}
|
|
|
|
public function setGenre(string $genre): void
|
|
{
|
|
if (!in_array($genre, Audio::genres)) {
|
|
$this->stateChanges("genre", null);
|
|
return;
|
|
}
|
|
|
|
$this->stateChanges("genre", $genre);
|
|
}
|
|
|
|
public function setCopyrightStatus(bool $withdrawn = true): void
|
|
{
|
|
$this->stateChanges("withdrawn", $withdrawn);
|
|
}
|
|
|
|
public function setSearchability(bool $searchable = true): void
|
|
{
|
|
$this->stateChanges("unlisted", !$searchable);
|
|
}
|
|
|
|
public function setToken(string $tok): void
|
|
{
|
|
throw new \LogicException("Changing keys is not supported.");
|
|
}
|
|
|
|
public function setKid(string $kid): void
|
|
{
|
|
throw new \LogicException("Changing keys is not supported.");
|
|
}
|
|
|
|
public function setKey(string $key): void
|
|
{
|
|
throw new \LogicException("Changing keys is not supported.");
|
|
}
|
|
|
|
public function setLength(int $len): void
|
|
{
|
|
throw new \LogicException("Changing length is not supported.");
|
|
}
|
|
|
|
public function setSegment_Size(int $len): void
|
|
{
|
|
throw new \LogicException("Changing length is not supported.");
|
|
}
|
|
|
|
public 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);
|
|
}
|
|
}
|