Implement Flysystem, move album art to filesystem, fix related issues #953 #962 (#1022)

* Initial entity and dependency commit.

* Create migration test for album art move.

* File management controller refactor and further wiring of Flysystem.

* Form/UI fixes and refactors related to Flysystem.

* Update composer deps and add merge plugin to avoid dep overlap.

* Fix batch moving/directory listing, add lightboxing to files manager.

* Fix album art writing, use special upload method to skip disk round-trip.

* Migrate StationRepository to be DI-driven, update unit tests and the setup controller.
This commit is contained in:
Buster "Silver Eagle" Neece 2018-12-05 01:15:51 -06:00 committed by GitHub
parent bb30feeee4
commit 70914a67c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1967 additions and 1206 deletions

View File

@ -32,7 +32,10 @@
"supervisorphp/supervisor": "^3.0",
"symfony/finder": "^4.1",
"symfony/process": "^4.1",
"ramsey/uuid": "^3.8"
"ramsey/uuid": "^3.8",
"league/flysystem": "^1.0",
"league/flysystem-cached-adapter": "^1.0",
"wikimedia/composer-merge-plugin": "^1.4"
},
"require-dev": {
"codeception/codeception": "^2.2",
@ -57,5 +60,19 @@
"preferred-install": "dist"
},
"prefer-stable": true,
"minimum-stability": "dev"
"minimum-stability": "dev",
"extra": {
"merge-plugin": {
"include": [
"plugins/*/composer.json"
],
"recurse": true,
"replace": false,
"ignore-duplicates": true,
"merge-dev": true,
"merge-extra": false,
"merge-extra-deep": false,
"merge-scripts": false
}
}
}

680
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ return [
'method' => 'post',
'elements' => [
'path' => [
'new_file' => [
'text',
[
'label' => __('File Name'),
@ -21,4 +21,4 @@ return [
],
],
];
];

View File

@ -339,22 +339,22 @@ return function(\Slim\App $app)
$this->group('/files', function () {
$this->get('', Controller\Stations\Files\FilesController::class.':indexAction')
$this->get('', Controller\Stations\Files\FilesController::class)
->setName('stations:files:index');
$this->map(['GET', 'POST'], '/edit/{id}', Controller\Stations\Files\EditController::class.':editAction')
$this->map(['GET', 'POST'], '/edit/{id}', Controller\Stations\Files\EditController::class)
->setName('stations:files:edit');
$this->map(['GET', 'POST'], '/rename/{path}', Controller\Stations\Files\FilesController::class.':renameAction')
$this->map(['GET', 'POST'], '/rename', Controller\Stations\Files\FilesController::class.':renameAction')
->setName('stations:files:rename');
$this->map(['GET', 'POST'], '/list', Controller\Stations\Files\FilesController::class.':listAction')
$this->map(['GET', 'POST'], '/list', Controller\Stations\Files\ListController::class)
->setName('stations:files:list');
$this->map(['GET', 'POST'], '/directories', Controller\Stations\Files\FilesController::class.':listDirectoriesAction')
->setName('stations:files:directories');
$this->map(['GET', 'POST'], '/batch', Controller\Stations\Files\FilesController::class.':batchAction')
$this->map(['GET', 'POST'], '/batch', Controller\Stations\Files\BatchController::class)
->setName('stations:files:batch');
$this->map(['GET', 'POST'], '/mkdir', Controller\Stations\Files\FilesController::class.':mkdirAction')

View File

@ -53,6 +53,35 @@ return function (\Azura\Container $di)
);
};
$di[\App\Entity\Repository\StationRepository::class] = function($di) {
/** @var \Doctrine\ORM\EntityManager $em */
$em = $di[\Doctrine\ORM\EntityManager::class];
return new \App\Entity\Repository\StationRepository(
$em,
$em->getClassMetadata(\App\Entity\Station::class),
$di[\App\Sync\Task\Media::class],
$di[\App\Radio\Adapters::class],
$di[\App\Radio\Configuration::class]
);
};
$di[\App\Entity\Repository\StationMediaRepository::class] = function($di) {
/** @var \Azura\Settings $settings */
$settings = $di['settings'];
// require_once($settings[\Azura\Settings::BASE_DIR] . '/vendor/james-heinrich/getid3/getid3/write.php');
/** @var \Doctrine\ORM\EntityManager $em */
$em = $di[\Doctrine\ORM\EntityManager::class];
return new \App\Entity\Repository\StationMediaRepository(
$em,
$em->getClassMetadata(\App\Entity\StationMedia::class),
$di[\App\Radio\Filesystem::class]
);
};
$di[\App\Entity\Repository\StationPlaylistMediaRepository::class] = function($di) {
/** @var \Doctrine\ORM\EntityManager $em */
$em = $di[\Doctrine\ORM\EntityManager::class];

View File

@ -74,7 +74,7 @@ $(function() {
var $link = '<a class="name" href="'+$url+'" title="'+row.name+'">'+(row.is_dir ? row.text : row.media_name)+'</a>';
var art_src = (row.media_art) ? row.media_art : '/static/img/generic_song.jpg';
var $art = (row.is_dir) ? '' : '<a href="'+art_src+'" target="_blank" class="float-right pl-3"><img style="width: 40px; height: auto; border-radius: 5px;" alt="<?=__('Album Artwork') ?>" src="'+art_src+'"></a>';
var $art = (row.is_dir) ? '' : '<a href="'+art_src+'" class="album-art float-right pl-3" target="_blank"><img style="width: 40px; height: auto; border-radius: 5px;" alt="<?=__('Album Artwork') ?>" src="'+art_src+'"></a>';
return '<div class="'+(row.is_dir ? 'is_dir' : 'is_file')+'">'+$art+$icon + $link + '<br><small>'+(row.is_dir ? 'Directory' : row.text)+'</small></div>';
},
@ -85,15 +85,24 @@ $(function() {
return '<a class="btn btn-sm btn-primary" href="'+row.rename_url+'"><?=__('Rename') ?></a>';
},
"file_length": function(column, row) {
if (row.media_length_text)
return row.media_length_text;
else
return 'N/A';
if (!row.media_length_text) {
return '';
}
return row.media_length_text;
},
"file_size": function(column, row) {
if (!row.size) {
return '';
}
return formatFileSize(row.size);
},
"file_mtime": function(column, row) {
if (!row.mtime) {
return '';
}
return formatTimestamp(row.mtime);
},
"playlists": function(column, row) {
@ -127,6 +136,9 @@ $(function() {
return false;
});
/* Handle album art clicking. */
grid.find('.album-art').fancybox({});
appMoveFilesModal.selected_files = getSelectedFiles().length;
}).on("selected.rs.jquery.bootgrid", function(e, columns, row) {
appMoveFilesModal.selected_files = getSelectedFiles().length;
@ -348,7 +360,6 @@ $(function() {
$files.push($(this).data('row-id'));
});
console.log($files);
return $files;
}

View File

@ -8,6 +8,7 @@ $assets
->load('bootgrid')
->load('moment')
->load('flowjs')
->load('fancybox')
->addInlineJs($this->fetch('stations/files/index.js', [
'csrf' => $csrf,
'max_upload_size' => $max_upload_size,
@ -58,7 +59,7 @@ $assets
<div class="col-sm-8">
<?=__('With selected:') ?>&nbsp;
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<i class="material-icons">add</i> <?=__('Add to Playlist') ?>
<span class="caret"></span>
</button>
@ -69,12 +70,12 @@ $assets
<li><a href="#" class="dropdown-item btn-new-playlist" data-toggle="modal" data-target="#mdl-create-playlist"><?=__('Create new...') ?></a></li>
</ul>
</div>
<a href="#" class="btn btn-default" data-toggle="modal" data-target="#mdl-move-file"><i class="material-icons">open_with</i> <?=__('Move') ?></a>
<button type="button" class="btn btn-warning" v-on:click="doBatch" data-action="clear"><i class="material-icons">clear_all</i> <?=__('Clear Playlists') ?></button>
<button type="button" class="btn btn-danger" v-on:click="doBatch" data-action="delete"><i class="material-icons">delete</i> <?=__('Delete') ?></button>
<a href="#" class="btn btn-sm btn-default" data-toggle="modal" data-target="#mdl-move-file"><i class="material-icons">open_with</i> <?=__('Move') ?></a>
<button type="button" class="btn btn-sm btn-warning" v-on:click="doBatch" data-action="clear"><i class="material-icons">clear_all</i> <?=__('Clear Playlists') ?></button>
<button type="button" class="btn btn-sm btn-danger" v-on:click="doBatch" data-action="delete"><i class="material-icons">delete</i> <?=__('Delete') ?></button>
</div>
<div class="col-sm-4 text-right">
<a class="btn btn-default" href="#" data-toggle="modal" data-target="#mdl-create-directory"><i class="material-icons">folder</i> <?=__('New Folder') ?></a>
<a class="btn btn-sm btn-default" href="#" data-toggle="modal" data-target="#mdl-create-directory"><i class="material-icons">folder</i> <?=__('New Folder') ?></a>
</div>
</div>
</div>
@ -131,8 +132,8 @@ $assets
<form id="frm-create-directory">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="exampleModalLabel"><?=__('New Directory') ?></h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<div class="form-group">
@ -154,8 +155,8 @@ $assets
<form id="frm-move-file">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="exampleModalLabel"><?=__('Move {{ selected_files }} File(s) to') ?></h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<div class="row">
@ -188,7 +189,3 @@ $assets
</form>
</div>
</div>
<script type="text/javascript" nonce="<?=$assets->getCspNonce() ?>">
</script>

View File

@ -29,31 +29,21 @@ class ReprocessMedia extends CommandAbstract
$em = $this->get(EntityManager::class);
$stations = $em->getRepository(Entity\Station::class)->findAll();
$song_repo = $em->getRepository(Entity\Song::class);
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $em->getRepository(Entity\StationMedia::class);
foreach ($stations as $station) {
/** @var Entity\Station $station */
$output->writeLn('Processing media for station: ' . $station->getName());
foreach($station->getMedia() as $media) {
/** @var Entity\StationMedia $media */
try {
if (empty($media->getUniqueId())) {
$media->generateUniqueId();
}
$song_info = $media->loadFromFile(true);
if (!empty($song_info)) {
$media->setSong($song_repo->getOrCreate($song_info));
}
$em->persist($media);
$output->writeLn('Processed: '.$media->getFullPath());
$media_repo->processMedia($media, true);
$output->writeLn('Processed: '.$media->getPath());
} catch (\Exception $e) {
$output->writeLn('Could not read source file for: '.$media->getFullPath().' - '.$e->getMessage());
$output->writeLn('Could not read source file for: '.$media->getPath().' - '.$e->getMessage());
continue;
}
}

View File

@ -114,7 +114,7 @@ class StationsController
$data = $form->getValues();
if (!($record instanceof Entity\Station)) {
$record = $this->record_repo->create($data, $this->adapters, $this->configuration);
$record = $this->record_repo->create($data);
} else {
$oldAdapter = $record->getFrontendType();
$this->record_repo->fromArray($record, $data);
@ -174,7 +174,7 @@ class StationsController
}
// Trigger normal creation process of station.
$new_record = $this->record_repo->create($new_record_data, $this->adapters, $this->configuration);
$new_record = $this->record_repo->create($new_record_data);
// Force port reassignment
$this->configuration->assignRadioPorts($new_record, true);
@ -243,7 +243,7 @@ class StationsController
$record = $this->record_repo->find((int)$id);
if ($record instanceof Entity\Station) {
$this->record_repo->destroy($record, $this->adapters, $this->configuration);
$this->record_repo->destroy($record);
}
$request->getSession()->flash(__('%s deleted.', __('Station')), 'green');

View File

@ -1,30 +1,30 @@
<?php
namespace App\Controller\Api\Stations;
use App\Url;
use App\Radio\Filesystem;
use App\Customization;
use Doctrine\ORM\EntityManager;
use App\Entity;
use App\Http\Request;
use App\Http\Response;
use Psr\Http\Message\ResponseInterface;
class MediaController
{
/** @var EntityManager */
protected $em;
/** @var Customization */
protected $customization;
/** @var Filesystem */
protected $filesystem;
/**
* @param EntityManager $em
* @param Customization $customization
* @param Filesystem $filesystem
*
* @see \App\Provider\ApiProvider
*/
public function __construct(EntityManager $em, Customization $customization)
public function __construct(Customization $customization, Filesystem $filesystem)
{
$this->em = $em;
$this->customization = $customization;
$this->filesystem = $filesystem;
}
/**
@ -34,11 +34,11 @@ class MediaController
* @OA\Parameter(ref="#/components/parameters/station_id_required"),
* @OA\Parameter(
* name="media_id",
* description="The station media ID",
* description="The station media unique ID",
* in="path",
* required=true,
* @OA\Schema(
* type="int64"
* type="string"
* )
* ),
* @OA\Response(
@ -53,14 +53,13 @@ class MediaController
*/
public function artAction(Request $request, Response $response, $station_id, $media_id): ResponseInterface
{
$media = $this->em->createQuery('SELECT sm, sa FROM '.Entity\StationMedia::class.' sm JOIN sm.art sa WHERE sm.station_id = :station_id AND sm.unique_id = :media_id')
->setParameter('station_id', $station_id)
->setParameter('media_id', $media_id)
->getOneOrNullResult();
$station = $request->getStation();
$filesystem = $this->filesystem->getForStation($station);
if ($media instanceof Entity\StationMedia) {
$media_path = 'albumart://'.$media_id.'.jpg';
$art = $media->getArt();
if ($filesystem->has($media_path)) {
$art = $filesystem->readStream($media_path);
if (is_resource($art)) {
return $response

View File

@ -23,12 +23,6 @@ class SetupController
/** @var Acl */
protected $acl;
/** @var Adapters */
protected $adapters;
/** @var Configuration */
protected $configuration;
/** @var array */
protected $station_form_config;
@ -39,18 +33,15 @@ class SetupController
* @param EntityManager $em
* @param Auth $auth
* @param Acl $acl
* @param Adapters $adapters
* @param Configuration $configuration
* @param array $station_form_config
* @param array $settings_form_config
*
* @see \App\Provider\FrontendProvider
*/
public function __construct(
EntityManager $em,
Auth $auth,
Acl $acl,
Adapters $adapters,
Configuration $configuration,
array $station_form_config,
array $settings_form_config
)
@ -58,8 +49,6 @@ class SetupController
$this->em = $em;
$this->auth = $auth;
$this->acl = $acl;
$this->adapters = $adapters;
$this->configuration = $configuration;
$this->station_form_config = $station_form_config;
$this->settings_form_config = $settings_form_config;
}
@ -160,7 +149,7 @@ class SetupController
/** @var Entity\Repository\StationRepository $station_repo */
$station_repo = $this->em->getRepository(Entity\Station::class);
$station_repo->create($data, $this->adapters, $this->configuration);
$station_repo->create($data);
return $response->withRedirect($request->getRouter()->named('setup:settings'));
}

View File

@ -0,0 +1,257 @@
<?php
namespace App\Controller\Stations\Files;
use App\Entity;
use App\Flysystem\StationFilesystem;
use App\Http\Request;
use App\Http\Response;
use App\Radio\Filesystem;
use Doctrine\ORM\EntityManager;
use Psr\Http\Message\ResponseInterface;
class BatchController extends FilesControllerAbstract
{
/** @var EntityManager */
protected $em;
/** @var Filesystem */
protected $filesystem;
/**
* BatchController constructor.
* @param EntityManager $em
* @param Filesystem $filesystem
*
* @see \App\Provider\StationsProvider
*/
public function __construct(EntityManager $em, Filesystem $filesystem)
{
$this->em = $em;
$this->filesystem = $filesystem;
}
public function __invoke(Request $request, Response $response, $station_id): ResponseInterface
{
try {
$request->getSession()->getCsrf()->verify($request->getParam('csrf'), $this->csrf_namespace);
} catch(\Azura\Exception\CsrfValidation $e) {
return $response->withStatus(403)
->withJson(['error' => ['code' => 403, 'msg' => 'CSRF Failure: '.$e->getMessage()]]);
}
$station = $request->getStation();
$fs = $this->filesystem->getForStation($station);
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
/** @var Entity\Repository\StationPlaylistMediaRepository $playlists_media_repo */
$playlists_media_repo = $this->em->getRepository(Entity\StationPlaylistMedia::class);
// Convert from pipe-separated files parameter into actual paths.
$files_raw = explode('|', $_POST['files']);
$files = [];
foreach ($files_raw as $file) {
$file_path = 'media://' . $file;
if ($fs->has($file_path)) {
$files[] = $file_path;
}
}
$files_found = 0;
$files_affected = 0;
$response_record = null;
$errors = [];
list($action, $action_id) = explode('_', $_POST['do']);
switch ($action) {
case 'delete':
// Remove the database entries of any music being removed.
$music_files = $this->_getMusicFiles($fs, $files);
$files_found = count($music_files);
foreach ($music_files as $file) {
try {
$media = $media_repo->findOneBy([
'station_id' => $station->getId(),
'path' => $file['path'],
]);
if ($media instanceof Entity\StationMedia) {
$this->em->remove($media);
}
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
}
$this->em->flush();
// Delete all selected files.
foreach ($files as $file) {
$file_meta = $fs->getMetadata($file);
if ('dir' === $file_meta['type']) {
$fs->deleteDir($file);
} else {
$fs->delete($file);
}
}
break;
case 'clear':
$backend = $request->getStationBackend();
// Clear all assigned playlists from the selected files.
$music_files = $this->_getMusicFiles($fs, $files);
$files_found = count($music_files);
foreach ($music_files as $file) {
try {
$media = $media_repo->getOrCreate($station, $file['path']);
$playlists_media_repo->clearPlaylistsFromMedia($media);
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
}
$this->em->flush();
// Write new PLS playlist configuration.
$backend->write($station);
break;
// Add all selected files to a playlist.
case 'playlist':
$backend = $request->getStationBackend();
if ($action_id === 'new') {
$playlist = new Entity\StationPlaylist($station);
$playlist->setName($_POST['name']);
$this->em->persist($playlist);
$this->em->flush();
$response_record = [
'id' => $playlist->getId(),
'name' => $playlist->getName(),
];
} else {
$playlist_id = (int)$action_id;
$playlist = $this->em->getRepository(Entity\StationPlaylist::class)->findOneBy([
'station_id' => $station->getId(),
'id' => $playlist_id
]);
if (!($playlist instanceof Entity\StationPlaylist)) {
return $this->_err($response, 500, 'Playlist Not Found');
}
}
$music_files = $this->_getMusicFiles($fs, $files);
$files_found = count($music_files);
$weight = $playlists_media_repo->getHighestSongWeight($playlist);
foreach ($music_files as $file) {
$weight++;
try {
$media = $media_repo->getOrCreate($station, $file['path']);
$weight = $playlists_media_repo->addMediaToPlaylist($media, $playlist, $weight);
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
}
$this->em->flush();
// Reshuffle the playlist if needed.
$playlists_media_repo->reshuffleMedia($playlist);
// Write new PLS playlist configuration.
$backend->write($station);
break;
case 'move':
$music_files = $this->_getMusicFiles($fs, $files);
$files_found = count($music_files);
$directory_path = $request->getParsedBody()['directory'];
$directory_path_full = 'media://'.$directory_path;
foreach ($music_files as $file) {
try {
$directory_path_meta = $fs->getMetadata($directory_path_full);
if ('dir' !== $directory_path_meta['type']) {
throw new \Azura\Exception(__('Path "%s" is not a folder.', $directory_path_full));
}
$media = $media_repo->getOrCreate($station, $file['path']);
$old_full_path = $media->getFullPath();
$media->setPath($directory_path . DIRECTORY_SEPARATOR . $file['basename']);
if (!$fs->rename($old_full_path, $media->getPath())) {
throw new \Azura\Exception(__('Could not move "%s" to "%s"', $old_full_path, $media->getFullPath()));
}
$this->em->persist($media);
$this->em->flush($media);
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
}
$this->em->flush();
break;
}
$this->em->clear(Entity\StationMedia::class);
$this->em->clear(Entity\StationPlaylist::class);
$this->em->clear(Entity\StationPlaylistMedia::class);
return $response->withJson([
'success' => true,
'files_found' => $files_found,
'files_affected' => $files_affected,
'errors' => $errors,
'record' => $response_record,
]);
}
protected function _getMusicFiles(StationFilesystem $fs, $path, $recursive = true)
{
if (is_array($path)) {
$music_files = [];
foreach ($path as $dir_file) {
$music_files = array_merge($music_files, $this->_getMusicFiles($fs, $dir_file, $recursive));
}
return $music_files;
}
$path_meta = $fs->getMetadata($path);
if ('file' === $path_meta['type']) {
return [$path_meta];
}
return array_filter($fs->listContents($path, $recursive), function($file) {
return ('file' === $file['type']);
});
}
}

View File

@ -4,6 +4,8 @@ namespace App\Controller\Stations\Files;
use App\Entity;
use App\Http\Request;
use App\Http\Response;
use App\Radio\Filesystem;
use Doctrine\ORM\EntityManager;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
@ -13,11 +15,40 @@ use Psr\Http\Message\UploadedFileInterface;
*/
class EditController extends FilesControllerAbstract
{
public function editAction(Request $request, Response $response, $station_id, $media_id): ResponseInterface
/** @var EntityManager */
protected $em;
/** @var Filesystem */
protected $filesystem;
/** @var array */
protected $form_config;
/**
* EditController constructor.
* @param EntityManager $em
* @param Filesystem $filesystem
* @param array $form_config
*
* @see \App\Provider\StationsProvider
*/
public function __construct(EntityManager $em, Filesystem $filesystem, array $form_config)
{
$this->em = $em;
$this->filesystem = $filesystem;
$this->form_config = $form_config;
}
public function __invoke(Request $request, Response $response, $station_id, $media_id): ResponseInterface
{
$station = $request->getStation();
$media = $this->media_repo->findOneBy([
$fs = $this->filesystem->getForStation($station);
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
$media = $media_repo->findOneBy([
'station_id' => $station_id,
'id' => $media_id
]);
@ -46,9 +77,9 @@ class EditController extends FilesControllerAbstract
$form = new \AzuraForms\Form($form_config);
// Populate custom fields in form.
$media_array = $this->media_repo->toArray($media);
$media_array = $media_repo->toArray($media);
if (!empty($custom_fields)) {
$media_array['custom_fields'] = $this->media_repo->getCustomFields($media);
$media_array['custom_fields'] = $media_repo->getCustomFields($media);
}
$form->populate($media_array);
@ -59,16 +90,16 @@ class EditController extends FilesControllerAbstract
// Detect rename.
if ($data['path'] !== $media->getPath()) {
list($data['path'], $path_full) = $this->_filterPath($station->getRadioMediaDir(), $data['path']);
rename($media->getFullPath(), $path_full);
$path_full = 'media://'.$data['path'];
$fs->rename($media->getFullPath(), $path_full);
}
if (!empty($custom_fields)) {
$this->media_repo->setCustomFields($media, $data['custom_fields']);
$media_repo->setCustomFields($media, $data['custom_fields']);
unset($data['custom_fields']);
}
$this->media_repo->fromArray($media, $data);
$media_repo->fromArray($media, $data);
// Handle uploaded artwork files.
$files = $request->getUploadedFiles();
@ -77,14 +108,13 @@ class EditController extends FilesControllerAbstract
/** @var UploadedFileInterface $file */
if ($file->getError() === UPLOAD_ERR_OK) {
$art_resource = imagecreatefromstring($file->getStream()->getContents());
$media->setArt($art_resource);
$media_repo->writeAlbumArt($media, $file->getStream()->getContents());
} else if ($file->getError() !== UPLOAD_ERR_NO_FILE) {
throw new \Azura\Exception('Error ' . $file->getError() . ' in uploaded file!');
}
}
if ($media->writeToFile()) {
if ($media_repo->writeToFile($media)) {
/** @var Entity\Repository\SongRepository $song_repo */
$song_repo = $this->em->getRepository(Entity\Song::class);

View File

@ -4,21 +4,39 @@ namespace App\Controller\Stations\Files;
use App\Entity;
use App\Http\Request;
use App\Http\Response;
use App\Utilities;
use App\Radio\Backend\BackendAbstract;
use App\Radio\Filesystem;
use Psr\Http\Message\ResponseInterface;
use App\Utilities;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Finder\Finder;
/**
* Class FilesController
*
* Uses components based on:
* Simple PHP File Manager - Copyright John Campbell (jcampbell1)
* License: MIT
*/
class FilesController extends FilesControllerAbstract
{
public function indexAction(Request $request, Response $response, $station_id): ResponseInterface
/** @var EntityManager */
protected $em;
/** @var Filesystem */
protected $filesystem;
/** @var array */
protected $form_config;
/**
* FilesController constructor.
* @param EntityManager $em
* @param Filesystem $filesystem
* @param array $form_config
*
* @see \App\Provider\StationsProvider
*/
public function __construct(EntityManager $em, Filesystem $filesystem, array $form_config)
{
$this->em = $em;
$this->filesystem = $filesystem;
$this->form_config = $form_config;
}
public function __invoke(Request $request, Response $response, $station_id): ResponseInterface
{
$station = $request->getStation();
@ -31,6 +49,7 @@ class FilesController extends FilesControllerAbstract
->getArrayResult();
// Show available file space in the station directory.
// TODO: This won't be applicable for stations that don't use local storage!
$media_dir = $station->getRadioMediaDir();
$space_free = disk_free_space($media_dir);
$space_total = disk_total_space($media_dir);
@ -66,26 +85,44 @@ class FilesController extends FilesControllerAbstract
]);
}
public function renameAction(Request $request, Response $response, $station_id, $path): ResponseInterface
protected function _asBytes($ini_v)
{
$ini_v = trim($ini_v);
$s = ['g' => 1 << 30, 'm' => 1 << 20, 'k' => 1 << 10];
return (int)$ini_v * ($s[strtolower(substr($ini_v, -1))] ?: 1);
}
public function renameAction(Request $request, Response $response, $station_id): ResponseInterface
{
$station = $request->getStation();
$fs = $this->filesystem->getForStation($station);
$path = base64_decode($path);
list($path, $path_full) = $this->_filterPath($station->getRadioMediaDir(), $path);
$path = $request->getAttribute('file');
$path_full = $request->getAttribute('file_path');
if (empty($path)) {
throw new \Azura\Exception('File not specified.');
}
$form = new \AzuraForms\Form($this->form_config);
$form->populate(['path' => $path]);
$form->populate([
'new_file' => $path,
]);
if (!empty($_POST) && $form->isValid()) {
$data = $form->getValues();
// Detect rename.
if ($data['path'] !== $path) {
list($new_path, $new_path_full) = $this->_filterPath($station->getRadioMediaDir(), $data['path']);
rename($path_full, $new_path_full);
if ($data['new_file'] !== $path) {
$new_path = $data['new_file'];
$new_path_full = 'media://'.$new_path;
if (is_dir($new_path_full)) {
// MountManager::rename's second argument is NOT the full URI >:(
$fs->rename($path_full, $new_path);
$path_meta = $fs->getMetadata($new_path_full);
if ('dir' === $path_meta['type']) {
// Update the paths of all media contained within the directory.
$media_in_dir = $this->em->createQuery('SELECT sm FROM '.Entity\StationMedia::class.' sm
WHERE sm.station_id = :station_id AND sm.path LIKE :path')
@ -119,394 +156,34 @@ class FilesController extends FilesControllerAbstract
]);
}
public function listAction(Request $request, Response $response, $station_id): ResponseInterface
{
$station = $request->getStation();
$result = [];
$file = $request->getAttribute('file');
$file_path = $request->getAttribute('file_path');
$search_phrase = trim($request->getParam('searchPhrase') ?? '');
if (!is_dir($file_path)) {
throw new \Azura\Exception(__('Path "%s" is not a folder.', $file));
}
$media_query = $this->em->createQueryBuilder()
->select('partial sm.{id, unique_id, path, length, length_text, artist, title, album}')
->addSelect('partial spm.{id}, partial sp.{id, name}')
->addSelect('partial smcf.{id, field_id, value}')
->from(Entity\StationMedia::class, 'sm')
->leftJoin('sm.custom_fields', 'smcf')
->leftJoin('sm.playlist_items', 'spm')
->leftJoin('spm.playlist', 'sp')
->where('sm.station_id = :station_id')
->andWhere('sm.path LIKE :path')
->setParameter('station_id', $station_id)
->setParameter('path', $file . '%');
// Apply searching
if (!empty($search_phrase)) {
if (substr($search_phrase, 0, 9) === 'playlist:') {
$playlist_name = substr($search_phrase, 9);
$media_query->andWhere('sp.name = :playlist_name')
->setParameter('playlist_name', $playlist_name);
} else {
$media_query->andWhere('(sm.title LIKE :query OR sm.artist LIKE :query)')
->setParameter('query', '%'.$search_phrase.'%');
}
}
$media_in_dir_raw = $media_query->getQuery()
->getArrayResult();
// Process all database results.
$media_in_dir = [];
foreach ($media_in_dir_raw as $media_row) {
$playlists = [];
foreach ($media_row['playlist_items'] as $playlist_row) {
$playlists[] = $playlist_row['playlist']['name'];
}
$custom_fields = [];
foreach($media_row['custom_fields'] as $custom_field) {
$custom_fields['custom_'.$custom_field['field_id']] = $custom_field['value'];
}
$media_in_dir[$media_row['path']] = [
'is_playable' => ($media_row['length'] !== 0),
'length' => $media_row['length'],
'length_text' => $media_row['length_text'],
'artist' => $media_row['artist'],
'title' => $media_row['title'],
'album' => $media_row['album'],
'name' => $media_row['artist'] . ' - ' . $media_row['title'],
'art' => (string)$this->router->named('api:stations:media:art', ['station' => $station_id, 'media_id' => $media_row['unique_id']]),
'edit_url' => (string)$this->router->named('stations:files:edit', ['station' => $station_id, 'id' => $media_row['id']]),
'play_url' => (string)$this->router->named('stations:files:download', ['station' => $station_id]) . '?file=' . urlencode($media_row['path']),
'playlists' => $playlists,
] + $custom_fields;
}
if (!empty($search_phrase)) {
$files = [];
foreach($media_in_dir as $short_path => $media_row) {
$files[] = $station->getRadioMediaDir().'/'.$short_path;
}
} else {
$finder = new Finder();
$finder->in($file_path)->depth('== 0');
$files = [];
foreach($finder as $finder_file) {
$files[] = $finder_file->getPathname();
}
}
foreach ($files as $i) {
$short = ltrim(str_replace($station->getRadioMediaDir(), '', $i), '/');
if (is_dir($i)) {
$media = ['name' => __('Directory'), 'playlists' => [], 'is_playable' => false];
} elseif (isset($media_in_dir[$short])) {
$media = $media_in_dir[$short];
} else {
$media = ['name' => __('File Not Processed'), 'playlists' => [], 'is_playable' => false];
}
$stat = stat($i);
$max_length = 60;
$shortname = basename($i);
if (mb_strlen($shortname) > $max_length) {
$shortname = mb_substr($shortname, 0, $max_length - 15) . '...' . mb_substr($shortname, -12);
}
$result_row = [
'mtime' => $stat['mtime'],
'size' => $stat['size'],
'name' => $short,
'path' => $short,
'text' => $shortname,
'is_dir' => is_dir($i),
'rename_url' => (string)$this->router->named('stations:files:rename', ['station' => $station_id, 'path' => base64_encode($short)]),
];
foreach ($media as $media_key => $media_val) {
$result_row['media_' . $media_key] = $media_val;
}
$result[] = $result_row;
}
// Example from bootgrid docs:
// current=1&rowCount=10&sort[sender]=asc&searchPhrase=&id=b0df282a-0d67-40e5-8558-c9e93b7befed
// Apply sorting and limiting.
$sort_by = ['is_dir', \SORT_DESC];
if (!empty($_REQUEST['sort'])) {
foreach ($_REQUEST['sort'] as $sort_key => $sort_direction) {
$sort_dir = (strtolower($sort_direction) === 'desc') ? \SORT_DESC : \SORT_ASC;
$sort_by[] = $sort_key;
$sort_by[] = $sort_dir;
}
} else {
$sort_by[] = 'name';
$sort_by[] = \SORT_ASC;
}
$result = \App\Utilities::array_order_by($result, $sort_by);
$num_results = count($result);
$page = @$_REQUEST['current'] ?: 1;
$row_count = @$_REQUEST['rowCount'] ?: 15;
if ($row_count == -1) {
$row_count = $num_results;
}
if ($num_results > 0 && $row_count > 0) {
$offset_start = ($page - 1) * $row_count;
if ($offset_start >= $num_results) {
$page = floor($num_results / $row_count);
$offset_start = ($page - 1) * $row_count;
}
$return_result = array_slice($result, $offset_start, $row_count);
} else {
$return_result = [];
}
return $response->withJson([
'current' => $page,
'rowCount' => $row_count,
'total' => $num_results,
'rows' => $return_result,
]);
}
public function listDirectoriesAction(Request $request, Response $response, $station_id): ResponseInterface
{
$station = $request->getStation();
$fs = $this->filesystem->getForStation($station);
$file_path = $request->getAttribute('file_path');
if (!is_dir($file_path)) {
throw new \Azura\Exception(__('Path "%s" is not a folder.', $file_path));
}
if (!empty($request->getAttribute('file'))) {
$file_meta = $fs->getMetadata($file_path);
$finder = new Finder();
$finder->directories()->in($file_path)->depth(0)->sortByName();
$directories = [];
foreach ($finder as $directory) {
$directories[] = [
'name' => $directory->getFilename(),
'path' => $directory->getRelativePathname()
];
}
return $response->withJson([
'rows' => $directories
]);
}
public function batchAction(Request $request, Response $response): ResponseInterface
{
try {
$request->getSession()->getCsrf()->verify($request->getParam('csrf'), $this->csrf_namespace);
} catch(\Azura\Exception\CsrfValidation $e) {
return $response->withStatus(403)
->withJson(['error' => ['code' => 403, 'msg' => 'CSRF Failure: '.$e->getMessage()]]);
}
$station = $request->getStation();
$base_dir = $station->getRadioMediaDir();
$files_raw = explode('|', $_POST['files']);
$files = [];
foreach ($files_raw as $file) {
$file_path = $base_dir . '/' . $file;
if (file_exists($file_path)) {
$files[] = $file_path;
if ('dir' !== $file_meta['type']) {
throw new \Azura\Exception(__('Path "%s" is not a folder.', $file_path));
}
}
$files_found = 0;
$files_affected = 0;
$directories = array_filter(array_map(function($file) {
if ('dir' !== $file['type']) {
return null;
}
$response_record = null;
$errors = [];
list($action, $action_id) = explode('_', $_POST['do']);
switch ($action) {
case 'delete':
// Remove the database entries of any music being removed.
$music_files = $this->_getMusicFiles($files);
$files_found = count($music_files);
foreach ($music_files as $file) {
try {
$media = $this->media_repo->findOneBy([
'station_id' => $station->getId(),
'path' => $station->getRelativeMediaPath($file)
]);
if ($media instanceof Entity\StationMedia) {
$this->em->remove($media);
}
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
@unlink($file);
}
$files_affected++;
}
$this->em->flush();
// Delete all selected files.
foreach ($files as $file) {
\App\Utilities::rmdir_recursive($file);
}
break;
case 'clear':
$backend = $request->getStationBackend();
// Clear all assigned playlists from the selected files.
$music_files = $this->_getMusicFiles($files);
$files_found = count($music_files);
foreach ($music_files as $file) {
try {
$media = $this->media_repo->getOrCreate($station, $file);
$this->playlists_media_repo->clearPlaylistsFromMedia($media);
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
}
$this->em->flush();
// Write new PLS playlist configuration.
$backend->write($station);
break;
// Add all selected files to a playlist.
case 'playlist':
/** @var BackendAbstract $backend */
$backend = $request->getAttribute('station_backend');
if ($action_id === 'new') {
$playlist = new Entity\StationPlaylist($station);
$playlist->setName($_POST['name']);
$this->em->persist($playlist);
$this->em->flush();
$response_record = [
'id' => $playlist->getId(),
'name' => $playlist->getName(),
];
} else {
$playlist_id = (int)$action_id;
$playlist = $this->em->getRepository(Entity\StationPlaylist::class)->findOneBy([
'station_id' => $station->getId(),
'id' => $playlist_id
]);
if (!($playlist instanceof Entity\StationPlaylist)) {
return $this->_err($response, 500, 'Playlist Not Found');
}
}
$music_files = $this->_getMusicFiles($files);
$files_found = count($music_files);
$weight = $this->playlists_media_repo->getHighestSongWeight($playlist);
foreach ($music_files as $file) {
$weight++;
try {
$media = $this->media_repo->getOrCreate($station, $file);
$weight = $this->playlists_media_repo->addMediaToPlaylist($media, $playlist, $weight);
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
}
$this->em->flush();
// Reshuffle the playlist if needed.
$this->playlists_media_repo->reshuffleMedia($playlist);
// Write new PLS playlist configuration.
$backend->write($station);
break;
case 'move':
$music_files = $this->_getMusicFiles($files);
$files_found = count($music_files);
$directory = $request->getParsedBody()['directory'];
list($directory_path, $directory_path_full) = $this->_filterPath($base_dir, $directory);
foreach ($music_files as $file) {
try {
if (is_dir($file)) {
continue;
}
if (!is_dir($directory_path_full)) {
throw new \Azura\Exception(__('Path "%s" is not a folder.', $directory_path_full));
}
$media = $this->media_repo->getOrCreate($station, $file);
$old_full_path = $media->getFullPath();
$media->setPath($directory_path . DIRECTORY_SEPARATOR . basename($file));
if (!rename($old_full_path, $media->getFullPath())) {
throw new \Azura\Exception(__('Could not move "%s" to "%s"', $old_full_path, $media->getFullPath()));
}
$this->em->persist($media);
$this->em->flush($media);
} catch (\Exception $e) {
$errors[] = $file.': '.$e->getMessage();
}
$files_affected++;
}
$this->em->flush();
break;
}
$this->em->clear(Entity\StationMedia::class);
$this->em->clear(Entity\StationPlaylist::class);
$this->em->clear(Entity\StationPlaylistMedia::class);
return [
'name' => $file['basename'],
'path' => $file['path'],
];
}, $fs->listContents($file_path)));
return $response->withJson([
'success' => true,
'files_found' => $files_found,
'files_affected' => $files_affected,
'errors' => $errors,
'record' => $response_record,
'rows' => array_values($directories)
]);
}
@ -515,20 +192,17 @@ class FilesController extends FilesControllerAbstract
try {
$request->getSession()->getCsrf()->verify($request->getParam('csrf'), $this->csrf_namespace);
} catch(\Azura\Exception\CsrfValidation $e) {
return $response->withStatus(403)
->withJson(['error' => ['code' => 403, 'msg' => 'CSRF Failure: '.$e->getMessage()]]);
return $this->_err($response, 403, 'CSRF Failure: '.$e->getMessage());
}
$file_path = $request->getAttribute('file_path');
// don't allow actions outside root. we also filter out slashes to catch args like './../outside'
$dir = $_POST['name'];
$dir = str_replace('/', '', $dir);
if (substr($dir, 0, 2) === '..') {
return $this->_err($response, 403, 'Cannot create directory: ..');
}
$station = $request->getStation();
$fs = $this->filesystem->getForStation($station);
if (!mkdir($file_path . '/' . $dir) && !is_dir($file_path . '/' . $dir)) {
$new_dir = $file_path.'/'.$_POST['name'];
$dir_created = $fs->createDir($new_dir);
if (!$dir_created) {
return $this->_err($response, 403, sprintf('Directory "%s" was not created', $file_path . '/' . $dir));
}
@ -544,6 +218,12 @@ class FilesController extends FilesControllerAbstract
->withJson(['error' => ['code' => 403, 'msg' => 'CSRF Failure: '.$e->getMessage()]]);
}
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
/** @var Entity\Repository\StationPlaylistMediaRepository $playlists_media_repo */
$playlists_media_repo = $this->em->getRepository(Entity\StationPlaylistMedia::class);
try {
$flow = new \App\Service\Flow($request, $response);
$flow_response = $flow->process();
@ -553,19 +233,18 @@ class FilesController extends FilesControllerAbstract
}
if (is_array($flow_response)) {
/** @var Entity\Station $station */
$station = $request->getAttribute('station');
$station = $request->getStation();
$file = $request->getAttribute('file');
$file_path = $request->getAttribute('file_path');
$file = new \Azura\File(basename($flow_response['filename']), $file_path);
$file->sanitizeName();
$sanitized_name = \Azura\File::sanitizeFileName(basename($flow_response['filename']));
$final_path = $file->getPath();
rename($flow_response['path'], $final_path);
$final_path = (empty($file))
? $file_path.$sanitized_name
: $file_path.'/'.$sanitized_name;
$station_media = $this->media_repo->getOrCreate($station, $final_path);
$this->em->persist($station_media);
$media_repo->uploadFile($station, $flow_response['path'], $final_path);
// If the user is looking at a playlist's contents, add uploaded media to that playlist.
if ($request->hasParam('searchPhrase')) {
@ -580,10 +259,10 @@ class FilesController extends FilesControllerAbstract
]);
if ($playlist instanceof Entity\StationPlaylist) {
$this->playlists_media_repo->addMediaToPlaylist($station_media, $playlist);
$playlists_media_repo->addMediaToPlaylist($station_media, $playlist);
$this->em->flush();
$this->playlists_media_repo->reshuffleMedia($playlist);
$playlists_media_repo->reshuffleMedia($playlist);
}
}
}
@ -603,10 +282,13 @@ class FilesController extends FilesControllerAbstract
{
set_time_limit(600);
$station = $request->getStation();
$file_path = $request->getAttribute('file_path');
$fs = $this->filesystem->getForStation($station);
$filename = basename($file_path);
$fh = fopen($file_path, 'rb');
$fh = $fs->readStream($file_path);
return $response
->withHeader('Content-Type', mime_content_type($file_path))
@ -615,66 +297,4 @@ class FilesController extends FilesControllerAbstract
strpos('MSIE', $_SERVER['HTTP_REFERER']) ? rawurlencode($filename) : "\"$filename\""))
->withBody(new \Slim\Http\Stream($fh));
}
protected function _getMusicFiles($path, $recursive = true)
{
if (is_array($path)) {
$music_files = [];
foreach ($path as $dir_file) {
$music_files = array_merge($music_files, $this->_getMusicFiles($dir_file, $recursive));
}
return $music_files;
}
if (!is_dir($path)) {
return [$path];
}
$finder = new Finder();
$finder = $finder->files()->in($path);
if (!$recursive) {
$finder = $finder->depth('== 0');
}
$music_files = [];
foreach($finder as $file) {
$music_files[] = $file->getPathname();
}
return $music_files;
}
protected function _is_recursively_deleteable($d)
{
$stack = [$d];
while ($dir = array_pop($stack)) {
if (!is_readable($dir) || !is_writable($dir)) {
return false;
}
$files = array_diff(scandir($dir, \SCANDIR_SORT_NONE), ['.', '..']);
foreach ($files as $file) {
if (is_dir($file)) {
$stack[] = "$dir/$file";
}
}
}
return true;
}
protected function _asBytes($ini_v)
{
$ini_v = trim($ini_v);
$s = ['g' => 1 << 30, 'm' => 1 << 20, 'k' => 1 << 10];
return (int)$ini_v * ($s[strtolower(substr($ini_v, -1))] ?: 1);
}
protected function _err(Response $response, $code, $msg)
{
return $response
->withStatus($code)
->withJson(['error' => ['code' => (int)$code, 'msg' => $msg]]);
}
}

View File

@ -1,68 +1,25 @@
<?php
namespace App\Controller\Stations\Files;
use Azura\Cache;
use App\Http\Router;
use Doctrine\ORM\EntityManager;
use App\Entity;
use App\Http\Response;
use Psr\Http\Message\ResponseInterface;
/**
* Class FilesControllerAbstract
*
* Uses components based on:
* Simple PHP File Manager - Copyright John Campbell (jcampbell1)
* License: MIT
*/
abstract class FilesControllerAbstract
{
/** @var EntityManager */
protected $em;
/** @var Router */
protected $router;
/** @var string */
protected $csrf_namespace = 'stations_files';
/** @var Cache */
protected $cache;
/** @var array */
protected $form_config;
/** @var Entity\Repository\StationMediaRepository */
protected $media_repo;
/** @var Entity\Repository\StationPlaylistMediaRepository */
protected $playlists_media_repo;
/**
* @param EntityManager $em
* @param Router $router
* @param Cache $cache
* @param array $form_config
* @see \App\Provider\StationsProvider
*/
public function __construct(EntityManager $em, Router $router, Cache $cache, array $form_config)
protected function _err(Response $response, $code, $msg): ResponseInterface
{
$this->em = $em;
$this->router = $router;
$this->cache = $cache;
$this->form_config = $form_config;
$this->media_repo = $this->em->getRepository(Entity\StationMedia::class);
$this->playlists_media_repo = $this->em->getRepository(Entity\StationPlaylistMedia::class);
}
protected function _filterPath($base_path, $path)
{
$path = str_replace(['../', './'], ['', ''], $path);
$path = trim($path, '/');
$dir_path = $base_path.DIRECTORY_SEPARATOR.dirname($path);
$full_path = $base_path.DIRECTORY_SEPARATOR.$path;
if ($real_path = realpath($dir_path)) {
if (substr($full_path, 0, strlen($base_path)) !== $base_path) {
throw new \Exception('New location not inside station media directory.');
}
} else {
throw new \Exception('Parent directory could not be resolved.');
}
return [$path, $full_path];
return $response
->withStatus($code)
->withJson(['error' => ['code' => (int)$code, 'msg' => $msg]]);
}
}

View File

@ -0,0 +1,197 @@
<?php
namespace App\Controller\Stations\Files;
use App\Entity;
use App\Http\Request;
use App\Http\Response;
use App\Radio\Filesystem;
use Doctrine\ORM\EntityManager;
use Psr\Http\Message\ResponseInterface;
class ListController extends FilesControllerAbstract
{
/** @var EntityManager */
protected $em;
/** @var Filesystem */
protected $filesystem;
/**
* ListController constructor.
* @param EntityManager $em
* @param Filesystem $filesystem
*
* @see \App\Provider\StationsProvider
*/
public function __construct(EntityManager $em, Filesystem $filesystem)
{
$this->em = $em;
$this->filesystem = $filesystem;
}
public function __invoke(Request $request, Response $response, $station_id): ResponseInterface
{
$station = $request->getStation();
$router = $request->getRouter();
$fs = $this->filesystem->getForStation($station);
$result = [];
$file = $request->getAttribute('file');
$file_path = $request->getAttribute('file_path');
$search_phrase = trim($request->getParam('searchPhrase') ?? '');
$media_query = $this->em->createQueryBuilder()
->select('partial sm.{id, unique_id, path, length, length_text, artist, title, album}')
->addSelect('partial spm.{id}, partial sp.{id, name}')
->addSelect('partial smcf.{id, field_id, value}')
->from(Entity\StationMedia::class, 'sm')
->leftJoin('sm.custom_fields', 'smcf')
->leftJoin('sm.playlist_items', 'spm')
->leftJoin('spm.playlist', 'sp')
->where('sm.station_id = :station_id')
->andWhere('sm.path LIKE :path')
->setParameter('station_id', $station_id)
->setParameter('path', $file . '%');
// Apply searching
if (!empty($search_phrase)) {
if (substr($search_phrase, 0, 9) === 'playlist:') {
$playlist_name = substr($search_phrase, 9);
$media_query->andWhere('sp.name = :playlist_name')
->setParameter('playlist_name', $playlist_name);
} else {
$media_query->andWhere('(sm.title LIKE :query OR sm.artist LIKE :query)')
->setParameter('query', '%'.$search_phrase.'%');
}
}
$media_in_dir_raw = $media_query->getQuery()
->getArrayResult();
// Process all database results.
$media_in_dir = [];
foreach ($media_in_dir_raw as $media_row) {
$playlists = [];
foreach ($media_row['playlist_items'] as $playlist_row) {
$playlists[] = $playlist_row['playlist']['name'];
}
$custom_fields = [];
foreach($media_row['custom_fields'] as $custom_field) {
$custom_fields['custom_'.$custom_field['field_id']] = $custom_field['value'];
}
$media_in_dir[$media_row['path']] = [
'is_playable' => ($media_row['length'] !== 0),
'length' => $media_row['length'],
'length_text' => $media_row['length_text'],
'artist' => $media_row['artist'],
'title' => $media_row['title'],
'album' => $media_row['album'],
'name' => $media_row['artist'] . ' - ' . $media_row['title'],
'art' => (string)$router->named('api:stations:media:art', ['station' => $station_id, 'media_id' => $media_row['unique_id']]),
'edit_url' => (string)$router->named('stations:files:edit', ['station' => $station_id, 'id' => $media_row['id']]),
'play_url' => (string)$router->named('stations:files:download', ['station' => $station_id]) . '?file=' . urlencode($media_row['path']),
'playlists' => $playlists,
] + $custom_fields;
}
$files = [];
if (!empty($search_phrase)) {
foreach($media_in_dir as $short_path => $media_row) {
$files[] = 'media://'.$short_path;
}
} else {
$files_raw = $fs->listContents($file_path);
foreach($files_raw as $file) {
$files[] = $file['filesystem'].'://'.$file['path'];
}
}
foreach ($files as $i) {
$short = str_replace('media://', '', $i);
$meta = $fs->getMetadata($i);
if ('dir' === $meta['type']) {
$media = ['name' => __('Directory'), 'playlists' => [], 'is_playable' => false];
} elseif (isset($media_in_dir[$short])) {
$media = $media_in_dir[$short];
} else {
$media = ['name' => __('File Not Processed'), 'playlists' => [], 'is_playable' => false];
}
$max_length = 60;
$shortname = $meta['basename'];
if (mb_strlen($shortname) > $max_length) {
$shortname = mb_substr($shortname, 0, $max_length - 15) . '...' . mb_substr($shortname, -12);
}
$result_row = [
'mtime' => $meta['timestamp'],
'size' => $meta['size'],
'name' => $short,
'path' => $short,
'text' => $shortname,
'is_dir' => ('dir' === $meta['type']),
'rename_url' => (string)$router->named('stations:files:rename', ['station' => $station_id], ['file' => $short]),
];
foreach ($media as $media_key => $media_val) {
$result_row['media_' . $media_key] = $media_val;
}
$result[] = $result_row;
}
// Example from bootgrid docs:
// current=1&rowCount=10&sort[sender]=asc&searchPhrase=&id=b0df282a-0d67-40e5-8558-c9e93b7befed
// Apply sorting and limiting.
$sort_by = ['is_dir', \SORT_DESC];
if (!empty($_REQUEST['sort'])) {
foreach ($_REQUEST['sort'] as $sort_key => $sort_direction) {
$sort_dir = (strtolower($sort_direction) === 'desc') ? \SORT_DESC : \SORT_ASC;
$sort_by[] = $sort_key;
$sort_by[] = $sort_dir;
}
} else {
$sort_by[] = 'name';
$sort_by[] = \SORT_ASC;
}
$result = \App\Utilities::array_order_by($result, $sort_by);
$num_results = count($result);
$page = @$_REQUEST['current'] ?: 1;
$row_count = @$_REQUEST['rowCount'] ?: 15;
if ($row_count == -1) {
$row_count = $num_results;
}
if ($num_results > 0 && $row_count > 0) {
$offset_start = ($page - 1) * $row_count;
if ($offset_start >= $num_results) {
$page = floor($num_results / $row_count);
$offset_start = ($page - 1) * $row_count;
}
$return_result = array_slice($result, $offset_start, $row_count);
} else {
$return_result = [];
}
return $response->withJson([
'current' => $page,
'rowCount' => $row_count,
'total' => $num_results,
'rows' => $return_result,
]);
}
}

View File

@ -0,0 +1,47 @@
<?php declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20181202180617 extends AbstractMigration
{
public function preUp(Schema $schema)
{
$stations = $this->connection->fetchAll('SELECT s.* FROM station AS s');
foreach($stations as $station) {
$base_dir = $station['radio_base_dir'];
$art_dir = $base_dir.'/album_art';
mkdir($art_dir, 0777);
$art_raw = $this->connection->fetchAll('SELECT sm.unique_id, sma.art FROM station_media AS sm JOIN station_media_art sma on sm.id = sma.media_id');
foreach($art_raw as $art_row) {
$art_path = $art_dir.'/'.$art_row['unique_id'].'.jpg';
file_put_contents($art_path, $art_row['art']);
}
}
}
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('DROP TABLE station_media_art');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('CREATE TABLE station_media_art (id INT AUTO_INCREMENT NOT NULL, media_id INT NOT NULL, art LONGBLOB DEFAULT NULL, UNIQUE INDEX UNIQ_35E0CAB2EA9FDD75 (media_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB');
$this->addSql('ALTER TABLE station_media_art ADD CONSTRAINT FK_35E0CAB2EA9FDD75 FOREIGN KEY (media_id) REFERENCES station_media (id) ON DELETE CASCADE');
}
}

View File

@ -2,10 +2,27 @@
namespace App\Entity\Repository;
use App\Entity;
use App\Radio\Filesystem;
use Azura\Doctrine\Repository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping;
class StationMediaRepository extends Repository
{
/** @var Filesystem */
protected $filesystem;
/** @var SongRepository */
protected $song_repo;
public function __construct($em, Mapping\ClassMetadata $class, Filesystem $filesystem)
{
parent::__construct($em, $class);
$this->filesystem = $filesystem;
$this->song_repo = $this->_em->getRepository(Entity\Song::class);
}
/**
* @param Entity\Station $station
* @return array
@ -37,6 +54,7 @@ class StationMediaRepository extends Repository
*/
public function search(Entity\Station $station, $query)
{
// TODO: Replace this!
$db = $this->_em->getConnection();
$table_name = $this->_em->getClassMetadata(__CLASS__)->getTableName();
@ -46,44 +64,313 @@ class StationMediaRepository extends Repository
return $stmt->fetchAll();
}
/**
* @param Entity\Station $station
* @param string $tmp_path
* @param string $dest
* @return Entity\StationMedia
*/
public function uploadFile(Entity\Station $station, $tmp_path, $dest): Entity\StationMedia
{
[$dest_prefix, $dest_path] = explode('://', $dest, 2);
$record = $this->findOneBy([
'station_id' => $station->getId(),
'path' => $dest_path,
]);
if (!($record instanceof Entity\StationMedia)) {
$record = new Entity\StationMedia($station, $dest_path);
}
$this->loadFromFile($record, $tmp_path);
$fs = $this->filesystem->getForStation($station);
$fs->upload($tmp_path, $dest);
$record->setMtime(time());
$this->_em->persist($record);
$this->_em->flush($record);
return $record;
}
/**
* @param Entity\Station $station
* @param $path
* @return Entity\StationMedia
* @throws \Exception
*/
public function getOrCreate(Entity\Station $station, $path)
public function getOrCreate(Entity\Station $station, $path): Entity\StationMedia
{
$short_path = $station->getRelativeMediaPath($path);
if (strpos($path, '://') !== false) {
[$path_prefix, $path] = explode('://', $path, 2);
}
$record = $this->findOneBy([
'station_id' => $station->getId(),
'path' => $short_path
'path' => $path
]);
$create_mode = false;
$created = false;
if (!($record instanceof Entity\StationMedia)) {
$record = new Entity\StationMedia($station, $short_path);
$create_mode = true;
$record = new Entity\StationMedia($station, $path);
$created = true;
}
$song_info = $record->loadFromFile();
if (is_array($song_info)) {
/** @var SongRepository $song_repo */
$song_repo = $this->_em->getRepository(Entity\Song::class);
$record->setSong($song_repo->getOrCreate($song_info));
}
$processed = $this->processMedia($record);
$this->_em->persist($record);
// Always flush new entities to generate a media ID.
if ($create_mode) {
if ($created) {
$this->_em->flush($record);
}
return $record;
}
/**
* Run media through the "processing" steps: loading from file and setting up any missing metadata.
*
* @param Entity\StationMedia $media
* @param bool $force
* @return Entity\StationMedia
* @throws \Doctrine\ORM\ORMException
* @throws \getid3_exception
*/
public function processMedia(Entity\StationMedia $media, $force = false): Entity\StationMedia
{
$media_uri = $media->getFullPath();
$fs = $this->filesystem->getForStation($media->getStation());
if (!$fs->has($media_uri)) {
return $media;
}
$media_mtime = $fs->getTimestamp($media_uri);
// No need to update if all of these conditions are true.
if (!$force && $media->songMatches() && $media_mtime <= $media->getMtime()) {
return $media;
}
$tmp_uri = $fs->copyToTemp($media_uri);
$tmp_path = $fs->getFullPath($tmp_uri);
$this->loadFromFile($media, $tmp_path);
$fs->delete($tmp_uri);
$media->setMtime($media_mtime);
$this->_em->persist($media);
return $media;
}
/**
* Process metadata information from media file.
*
* @param Entity\StationMedia $media
* @param null $file_path
* @throws \getid3_exception
*/
public function loadFromFile(Entity\StationMedia $media, $file_path = null): void
{
// Load metadata from supported files.
$id3 = new \getID3();
$id3->option_md5_data = true;
$id3->option_md5_data_source = true;
$id3->encoding = 'UTF-8';
$file_info = $id3->analyze($file_path);
if (empty($file_info['error'])) {
$media->setLength($file_info['playtime_seconds']);
$tags_to_set = ['title', 'artist', 'album'];
if (!empty($file_info['tags'])) {
foreach ($file_info['tags'] as $tag_type => $tag_data) {
foreach ($tags_to_set as $tag) {
if (!empty($tag_data[$tag][0])) {
$media->{'set'.ucfirst($tag)}(mb_convert_encoding($tag_data[$tag][0], "UTF-8"));
}
}
if (!empty($tag_data['unsynchronized_lyric'][0])) {
$media->setLyrics($tag_data['unsynchronized_lyric'][0]);
}
}
}
if (!empty($file_info['attached_picture'][0])) {
$picture = $file_info['attached_picture'][0];
$this->writeAlbumArt($media, $picture['data']);
} else if (!empty($file_info['comments']['picture'][0])) {
$picture = $file_info['comments']['picture'][0];
$this->writeAlbumArt($media, $picture['data']);
}
}
// Attempt to derive title and artist from filename.
if (empty($media->getTitle())) {
$filename = pathinfo($media->getPath(), PATHINFO_FILENAME);
$filename = str_replace('_', ' ', $filename);
$string_parts = explode('-', $filename);
// If not normally delimited, return "text" only.
if (count($string_parts) == 1) {
$media->setTitle(trim($filename));
$media->setArtist('');
} else {
$media->setTitle(trim(array_pop($string_parts)));
$media->setArtist(trim(implode('-', $string_parts)));
}
}
$media->setSong($this->song_repo->getOrCreate([
'artist' => $media->getArtist(),
'title' => $media->getTitle(),
]));
}
/**
* Write modified metadata directly to the file as ID3 information.
*
* @param Entity\StationMedia $media
* @return bool
* @throws \getid3_exception
*/
public function writeToFile(Entity\StationMedia $media): bool
{
$fs = $this->filesystem->getForStation($media->getStation());
$getID3 = new \getID3;
$getID3->setOption(['encoding' => 'UTF8']);
$tmp_uri = $fs->copyToTemp($media->getFullPath());
$tmp_path = $fs->getFullPath($tmp_uri);
$tagwriter = new \getid3_writetags;
$tagwriter->filename = $tmp_path;
$tagwriter->tagformats = ['id3v1', 'id3v2.3'];
$tagwriter->overwrite_tags = true;
$tagwriter->tag_encoding = 'UTF8';
$tagwriter->remove_other_tags = true;
$tag_data = [
'title' => [
$media->getTitle()
],
'artist' => [
$media->getArtist()
],
'album' => [
$media->getAlbum()
],
'unsynchronized_lyric' => [
$media->getLyrics()
],
];
$art_path = $media->getArtPath();
if ($fs->has($art_path)) {
$tag_data['attached_picture'][0] = [
'encodingid' => 0, // ISO-8859-1; 3=UTF8 but only allowed in ID3v2.4
'description' => 'cover art',
'data' => $fs->read($art_path),
'picturetypeid' => 0x03,
'mime' => 'image/jpeg',
];
$tag_data['comments']['picture'][0] = $tag_data['attached_picture'][0];
}
$tagwriter->tag_data = $tag_data;
// write tags
if ($tagwriter->WriteTags()) {
$media->setMtime(time());
$fs->updateFromTemp($tmp_uri, $media->getFullPath());
return true;
}
return false;
}
/**
* Crop album art and write the resulting image to storage.
*
* @param Entity\StationMedia $media
* @param string $raw_art_string The raw image data, as would be retrieved from file_get_contents.
* @return bool
*/
public function writeAlbumArt(Entity\StationMedia $media, $raw_art_string): bool
{
$source_gd_image = imagecreatefromstring($raw_art_string);
if (!is_resource($source_gd_image)) {
return false;
}
// Crop the raw art to a 1200x1200 artboard.
$dest_max_width = 1200;
$dest_max_height = 1200;
$source_image_width = imagesx($source_gd_image);
$source_image_height = imagesy($source_gd_image);
$source_aspect_ratio = $source_image_width / $source_image_height;
$thumbnail_aspect_ratio = $dest_max_width / $dest_max_height;
if ($source_image_width <= $dest_max_width && $source_image_height <= $dest_max_height) {
$thumbnail_image_width = $source_image_width;
$thumbnail_image_height = $source_image_height;
} elseif ($thumbnail_aspect_ratio > $source_aspect_ratio) {
$thumbnail_image_width = (int) ($dest_max_height * $source_aspect_ratio);
$thumbnail_image_height = $dest_max_height;
} else {
$thumbnail_image_width = $dest_max_width;
$thumbnail_image_height = (int) ($dest_max_width / $source_aspect_ratio);
}
$thumbnail_gd_image = imagecreatetruecolor($thumbnail_image_width, $thumbnail_image_height);
imagecopyresampled($thumbnail_gd_image, $source_gd_image, 0, 0, 0, 0, $thumbnail_image_width, $thumbnail_image_height, $source_image_width, $source_image_height);
ob_start();
imagejpeg($thumbnail_gd_image, NULL, 90);
$album_art = ob_get_contents();
ob_end_clean();
imagedestroy($source_gd_image);
imagedestroy($thumbnail_gd_image);
$album_art_path = $media->getArtPath();
$fs = $this->filesystem->getForStation($media->getStation());
return $fs->put($album_art_path, $album_art);
}
/**
* Read the contents of the album art from storage (if it exists).
*
* @param Entity\StationMedia $media
* @return string|null
*/
public function readAlbumArt(Entity\StationMedia $media): ?string
{
$album_art_path = $media->getArtPath();
$fs = $this->filesystem->getForStation($media->getStation());
if (!$fs->has($album_art_path)) {
return null;
}
return $fs->read($album_art_path);
}
/**
* Retrieve a key-value representation of all custom metadata for the specified media.
*

View File

@ -5,10 +5,36 @@ use App\Radio\Adapters;
use App\Radio\Configuration;
use App\Radio\Frontend\FrontendAbstract;
use App\Entity;
use App\Sync\Task\Media;
use Azura\Doctrine\Repository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping;
class StationRepository extends Repository
{
/** @var Media */
protected $media_sync;
/** @var Adapters */
protected $adapters;
/** @var Configuration */
protected $configuration;
public function __construct(
$em,
Mapping\ClassMetadata $class,
Media $media_sync,
Adapters $adapters,
Configuration $configuration
) {
parent::__construct($em, $class);
$this->media_sync = $media_sync;
$this->adapters = $adapters;
$this->configuration = $configuration;
}
/**
* @return mixed
*/
@ -60,12 +86,10 @@ class StationRepository extends Repository
* Create a station based on the specified data.
*
* @param array $data Array of data to populate the station with.
* @param Adapters $adapters
* @param Configuration $configuration
* @return Entity\Station
* @throws \Exception
*/
public function create($data, Adapters $adapters, Configuration $configuration)
public function create($data)
{
$station = new Entity\Station;
$this->fromArray($station, $data);
@ -82,18 +106,16 @@ class StationRepository extends Repository
$this->_em->flush();
// Scan directory for any existing files.
$media_sync = new \App\Sync\Task\Media($this->_em);
set_time_limit(600);
$media_sync->importMusic($station);
$this->media_sync->importMusic($station);
$this->_em->refresh($station);
$media_sync->importPlaylists($station);
$this->media_sync->importPlaylists($station);
$this->_em->refresh($station);
// Load adapters.
$frontend_adapter = $adapters->getFrontendAdapter($station);
$backend_adapter = $adapters->getBackendAdapter($station);
$frontend_adapter = $this->adapters->getFrontendAdapter($station);
$backend_adapter = $this->adapters->getBackendAdapter($station);
// Create default mountpoints if station supports them.
$this->resetMounts($station, $frontend_adapter);
@ -102,7 +124,7 @@ class StationRepository extends Repository
$frontend_adapter->read($station);
// Write the adapter configurations and update supervisord.
$configuration->writeConfiguration($station, true);
$this->configuration->writeConfiguration($station, true);
// Save changes and continue to the last setup step.
$this->_em->persist($station);
@ -142,13 +164,11 @@ class StationRepository extends Repository
/**
* @param Entity\Station $station
* @param Adapters $adapters
* @param Configuration $configuration
* @throws \Exception
*/
public function destroy(Entity\Station $station, Adapters $adapters, Configuration $configuration)
public function destroy(Entity\Station $station)
{
$configuration->removeConfiguration($station);
$this->configuration->removeConfiguration($station);
// Remove media folders.
$radio_dir = $station->getRadioBaseDir();

View File

@ -545,8 +545,10 @@ class Station
$radio_dirs = [
$this->radio_base_dir,
$this->getRadioMediaDir(),
$this->getRadioAlbumArtDir(),
$this->getRadioPlaylistsDir(),
$this->getRadioConfigDir()
$this->getRadioConfigDir(),
$this->getRadioTempDir(),
];
foreach ($radio_dirs as $radio_dir) {
if (!file_exists($radio_dir)) {
@ -568,6 +570,22 @@ class Station
: $this->radio_base_dir.'/media';
}
/**
* @return string
*/
public function getRadioAlbumArtDir(): string
{
return $this->radio_base_dir.'/album_art';
}
/**
* @return string
*/
public function getRadioTempDir(): string
{
return $this->radio_base_dir.'/temp';
}
/**
* Given an absolute path, return a path relative to this station's media directory.
*

View File

@ -14,7 +14,6 @@ use Psr\Http\Message\UriInterface;
* @UniqueConstraint(name="path_unique_idx", columns={"path", "station_id"})
* })
* @Entity(repositoryClass="App\Entity\Repository\StationMediaRepository")
* @HasLifecycleCallbacks
*/
class StationMedia
{
@ -82,12 +81,6 @@ class StationMedia
*/
protected $lyrics;
/**
* @OneToOne(targetEntity="StationMediaArt", mappedBy="media", cascade={"persist"})
* @var StationMediaArt
*/
protected $art;
/**
* @Column(name="isrc", type="string", length=15, nullable=true)
* @var string|null The track ISRC (International Standard Recording Code), used for licensing purposes.
@ -163,7 +156,6 @@ class StationMedia
public function __construct(Station $station, string $path)
{
$this->station = $station;
$this->path = $path;
$this->length = 0;
$this->length_text = '0:00';
@ -172,6 +164,9 @@ class StationMedia
$this->playlist_items = new ArrayCollection;
$this->custom_fields = new ArrayCollection;
$this->setPath($path);
$this->generateUniqueId();
}
/**
@ -206,6 +201,24 @@ class StationMedia
$this->song = $song;
}
/**
* Check if the hash of the associated Song record matches the hash that would be
* generated by this record's artist and title metadata. Used to determine if a
* record should be reprocessed or not.
*
* @return bool
*/
public function songMatches(): bool
{
$expected_song_hash = Song::getSongHash([
'artist' => $this->artist,
'title' => $this->title,
]);
return (null !== $this->song_id)
&& ($this->song_id === $expected_song_hash);
}
/**
* @return null|string
*/
@ -271,27 +284,13 @@ class StationMedia
}
/**
* @return null|resource
* Get the Flysystem URI for album artwork for this item.
*
* @return string
*/
public function getArt()
public function getArtPath()
{
if ($this->art instanceof StationMediaArt) {
return $this->art->getArt();
}
return null;
}
/**
* @param resource $source_image_path A GD image manipulation resource.
* @return bool
*/
public function setArt($source_gd_image = null)
{
if (!($this->art instanceof StationMediaArt)) {
$this->art = new StationMediaArt($this);
}
return $this->art->setArt($source_gd_image);
return 'albumart://'.$this->unique_id.'.jpg';
}
/**
@ -362,6 +361,16 @@ class StationMedia
$this->path = $path;
}
/**
* Return the abstracted "full path" filesystem URI for this record.
*
* @return string
*/
public function getFullPath(): string
{
return 'media://'.$this->path;
}
/**
* @return int|null
*/
@ -558,150 +567,6 @@ class StationMedia
return $annotations;
}
/**
* Process metadata information from media file.
*
* @param bool $force
* @return array|bool
* - Array containing song information, if one is detected and needs updating
* - False if information was not updated
*/
public function loadFromFile($force = false)
{
if (empty($this->path)) {
return false;
}
$media_base_dir = $this->station->getRadioMediaDir();
$media_path = $media_base_dir . '/' . $this->path;
$path_parts = pathinfo($media_path);
// Only update metadata if the file has been updated.
$media_mtime = filemtime($media_path);
// Check for a hash mismatch.
$expected_song_hash = Song::getSongHash([
'artist' => $this->artist,
'title' => $this->title,
]);
if ($media_mtime > $this->mtime
|| null === $this->song_id
|| $this->song_id != $expected_song_hash
|| $force) {
$this->mtime = $media_mtime;
// Load metadata from supported files.
$id3 = new \getID3();
$id3->option_md5_data = true;
$id3->option_md5_data_source = true;
$id3->encoding = 'UTF-8';
$file_info = $id3->analyze($media_path);
if (empty($file_info['error'])) {
$this->setLength($file_info['playtime_seconds']);
$tags_to_set = ['title', 'artist', 'album'];
if (!empty($file_info['tags'])) {
foreach ($file_info['tags'] as $tag_type => $tag_data) {
foreach ($tags_to_set as $tag) {
if (!empty($tag_data[$tag][0])) {
$this->{'set'.ucfirst($tag)}(mb_convert_encoding($tag_data[$tag][0], "UTF-8"));
}
}
if (!empty($tag_data['unsynchronized_lyric'][0])) {
$this->setLyrics($tag_data['unsynchronized_lyric'][0]);
}
}
}
if (!empty($file_info['comments']['picture'][0])) {
$picture = $file_info['comments']['picture'][0];
$this->setArt(imagecreatefromstring($picture['data']));
}
}
// Attempt to derive title and artist from filename.
if (empty($this->title)) {
$filename = str_replace('_', ' ', $path_parts['filename']);
$string_parts = explode('-', $filename);
// If not normally delimited, return "text" only.
if (count($string_parts) == 1) {
$this->setTitle(trim($filename));
$this->setArtist('');
} else {
$this->setTitle(trim(array_pop($string_parts)));
$this->setArtist(trim(implode('-', $string_parts)));
}
}
return [
'artist' => $this->artist,
'title' => $this->title,
];
}
return false;
}
/**
* Write modified metadata directly to the file as ID3 information.
*/
public function writeToFile()
{
$getID3 = new \getID3;
$getID3->setOption(['encoding' => 'UTF8']);
require_once(APP_INCLUDE_ROOT . '/vendor/james-heinrich/getid3/getid3/write.php');
$tagwriter = new \getid3_writetags;
$tagwriter->filename = $this->getFullPath();
$tagwriter->tagformats = ['id3v1', 'id3v2.3'];
$tagwriter->overwrite_tags = true;
$tagwriter->tag_encoding = 'UTF8';
$tagwriter->remove_other_tags = true;
$tag_data = [
'title' => [$this->title],
'artist' => [$this->artist],
'album' => [$this->album],
];
if (is_resource($this->art)) {
$tag_data['attached_picture'][0] = [
'data' => stream_get_contents($this->art),
'picturetypeid' => 'image/jpeg',
'mime' => 'image/jpeg',
];
$tag_data['comments']['picture'][0] = $tag_data['attached_picture'][0];
}
$tagwriter->tag_data = $tag_data;
// write tags
if ($tagwriter->WriteTags()) {
$this->mtime = time();
return true;
}
return false;
}
public function getFullPath()
{
$media_base_dir = $this->station->getRadioMediaDir();
return $media_base_dir . '/' . $this->path;
}
/**
* Indicates whether this media is a part of any "requestable" playlists.
*

View File

@ -1,101 +0,0 @@
<?php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
/**
* @Table(name="station_media_art")
* @Entity
*/
class StationMediaArt
{
/**
* @Column(name="id", type="integer")
* @Id
* @GeneratedValue(strategy="IDENTITY")
* @var int
*/
protected $id;
/**
* @Column(name="media_id", type="integer")
* @var int
*/
protected $media_id;
/**
* @OneToOne(targetEntity="StationMedia", inversedBy="art")
* @JoinColumn(name="media_id", referencedColumnName="id", onDelete="CASCADE")
* @var StationMedia
*/
protected $media;
/**
* @Column(name="art", type="blob", nullable=true)
* @var resource|null
*/
protected $art;
public function __construct(StationMedia $media)
{
$this->media = $media;
}
public function getMedia(): StationMedia
{
return $this->media;
}
/**
* @return null|resource
*/
public function getArt()
{
return $this->art;
}
/**
* @param resource $source_image_path A GD image manipulation resource.
* @return bool
*/
public function setArt($source_gd_image = null)
{
if (!is_resource($source_gd_image)) {
return false;
}
$dest_max_width = 1200;
$dest_max_height = 1200;
$source_image_width = imagesx($source_gd_image);
$source_image_height = imagesy($source_gd_image);
$source_aspect_ratio = $source_image_width / $source_image_height;
$thumbnail_aspect_ratio = $dest_max_width / $dest_max_height;
if ($source_image_width <= $dest_max_width && $source_image_height <= $dest_max_height) {
$thumbnail_image_width = $source_image_width;
$thumbnail_image_height = $source_image_height;
} elseif ($thumbnail_aspect_ratio > $source_aspect_ratio) {
$thumbnail_image_width = (int) ($dest_max_height * $source_aspect_ratio);
$thumbnail_image_height = $dest_max_height;
} else {
$thumbnail_image_width = $dest_max_width;
$thumbnail_image_height = (int) ($dest_max_width / $source_aspect_ratio);
}
$thumbnail_gd_image = imagecreatetruecolor($thumbnail_image_width, $thumbnail_image_height);
imagecopyresampled($thumbnail_gd_image, $source_gd_image, 0, 0, 0, 0, $thumbnail_image_width, $thumbnail_image_height, $source_image_width, $source_image_height);
ob_start();
imagejpeg($thumbnail_gd_image, NULL, 90);
$this->art = ob_get_contents();
ob_end_clean();
imagedestroy($source_gd_image);
imagedestroy($thumbnail_gd_image);
return true;
}
}

View File

@ -1,12 +1,8 @@
<?php
namespace App\Entity\Traits;
/**
* @HasLifecycleCallbacks
*/
trait UniqueId
{
/**
* @Column(name="unique_id", type="string", length=25, nullable=true)
* @var string
@ -23,10 +19,11 @@ trait UniqueId
/**
* Generate a new unique ID for this item.
* @PrePersist
*/
public function generateUniqueId()
public function generateUniqueId($force_new = false)
{
$this->unique_id = bin2hex(random_bytes(12));
if (empty($this->unique_id) || $force_new) {
$this->unique_id = bin2hex(random_bytes(12));
}
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace App\Flysystem;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Cached\CachedAdapter;
use League\Flysystem\Filesystem;
use League\Flysystem\MountManager;
class StationFilesystem extends MountManager
{
/**
* Copy a file from the specified path to the temp directory
*
* @param string $from The permanent path to copy from
* @param string|null $to The temporary path to copy to (temp://original if not specified)
* @return string The temporary path
*/
public function copyToTemp($from, $to = null): string
{
list($prefix_from, $path_from) = $this->getPrefixAndPath($from);
if (null === $to) {
$random_prefix = substr(md5(random_bytes(8)), 0, 5);
$to = 'temp://'.$random_prefix.'_'.$path_from;
}
if ($this->has($to)) {
$this->delete($to);
}
$this->copy($from, $to);
return $to;
}
/**
* Update the value of a permanent file from a temporary directory.
*
* @param string $from The temporary path to update from
* @param string $to The permanent path to update to
* @param array $config
* @return string
*/
public function updateFromTemp($from, $to, array $config = []): string
{
$buffer = $this->readStream($from);
if ($buffer === false) {
return false;
}
$written = $this->putStream($to, $buffer, $config);
if (is_resource($buffer)) {
fclose($buffer);
}
if ($written) {
$this->delete($from);
}
return $to;
}
/**
* "Upload" a local path into the Flysystem abstract filesystem.
*
* @param $local_path
* @param $to
* @param array $config
* @return bool
*/
public function upload($local_path, $to, array $config = []): bool
{
$stream = fopen($local_path, 'r+');
$uploaded = $this->putStream($to, $stream);
if (is_resource($stream)) {
fclose($stream);
}
if ($uploaded) {
@unlink($local_path);
return true;
}
return false;
}
/**
* If the adapter associated with the specified URI is a local one, get the full filesystem path.
*
* NOTE: This can only be assured for the temp:// and config:// prefixes. Other prefixes can (and will)
* use non-local adapters that will trigger an exception here.
*
* @param string $uri
* @return string
*/
public function getFullPath($uri): string
{
list($prefix, $path) = $this->getPrefixAndPath($uri);
$fs = $this->getFilesystem($prefix);
if (!($fs instanceof Filesystem)) {
throw new \InvalidArgumentException(sprintf('Filesystem for "%s" is not an instance of Filesystem.', $prefix));
}
$adapter = $fs->getAdapter();
if ($adapter instanceof CachedAdapter) {
$adapter = $adapter->getAdapter();
}
if (!($adapter instanceof Local)) {
throw new \InvalidArgumentException(sprintf('Adapter for "%s" is not a Local or cached Local adapter.', $prefix));
}
$prefix = $adapter->getPathPrefix();
return $prefix.$path;
}
}

View File

@ -19,32 +19,17 @@ class StationFiles
*/
public function __invoke(Request $request, Response $response, $next): Response
{
$station = $request->getStation();
$backend = $request->getStationBackend();
if (!$backend::supportsMedia()) {
throw new \Azura\Exception(__('This feature is not currently supported on this station.'));
}
$base_dir = $station->getRadioMediaDir();
$file = $request->getParam('file', '');
$file_path = realpath($base_dir . '/' . $file);
if ($file_path === false) {
return $response->withStatus(404)
->withJson(['error' => ['code' => 404, 'msg' => 'File or Directory Not Found']]);
}
// Sanity check that the final file path is still within the base directory
if (substr($file_path, 0, strlen($base_dir)) !== $base_dir) {
return $response->withStatus(403)
->withJson(['error' => ['code' => 403, 'msg' => 'Forbidden']]);
}
$file_path = 'media://'.$file;
$request = $request->withAttribute('file', $file)
->withAttribute('file_path', $file_path)
->withAttribute('base_dir', $base_dir);
->withAttribute('file_path', $file_path);
return $next($request, $response);
}

View File

@ -59,8 +59,8 @@ class ApiProvider implements ServiceProviderInterface
$di[Api\Stations\MediaController::class] = function($di) {
return new Api\Stations\MediaController(
$di[\Doctrine\ORM\EntityManager::class],
$di[\App\Customization::class]
$di[\App\Customization::class],
$di[\App\Radio\Filesystem::class]
);
};

View File

@ -69,8 +69,6 @@ class FrontendProvider implements ServiceProviderInterface
$di[\Doctrine\ORM\EntityManager::class],
$di[\App\Auth::class],
$di[\App\Acl::class],
$di[\App\Radio\Adapters::class],
$di[\App\Radio\Configuration::class],
$config->get('forms/station'),
$config->get('forms/settings')
);

View File

@ -1,12 +1,7 @@
<?php
namespace App\Provider;
use App\Radio\Adapters;
use App\Radio\AutoDJ;
use App\Radio\Backend;
use App\Radio\Configuration;
use App\Radio\Frontend;
use App\Radio\Remote;
use App\Radio;
use Pimple\ServiceProviderInterface;
use Pimple\Container;
@ -14,46 +9,46 @@ class RadioProvider implements ServiceProviderInterface
{
public function register(Container $di)
{
$di[Adapters::class] = function($di) {
return new Adapters(new \Pimple\Psr11\ServiceLocator($di, [
Backend\Liquidsoap::class,
Backend\None::class,
Frontend\Icecast::class,
Frontend\Remote::class,
Frontend\SHOUTcast::class,
Remote\Icecast::class,
Remote\SHOUTcast1::class,
Remote\SHOUTcast2::class,
$di[Radio\Adapters::class] = function($di) {
return new Radio\Adapters(new \Pimple\Psr11\ServiceLocator($di, [
Radio\Backend\Liquidsoap::class,
Radio\Backend\None::class,
Radio\Frontend\Icecast::class,
Radio\Frontend\Remote::class,
Radio\Frontend\SHOUTcast::class,
Radio\Remote\Icecast::class,
Radio\Remote\SHOUTcast1::class,
Radio\Remote\SHOUTcast2::class,
]));
};
$di[AutoDJ::class] = function($di) {
return new AutoDJ(
$di[Radio\AutoDJ::class] = function($di) {
return new Radio\AutoDJ(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Azura\EventDispatcher::class]
);
};
$di[Configuration::class] = function($di) {
return new Configuration(
$di[Radio\Configuration::class] = function($di) {
return new Radio\Configuration(
$di[\Doctrine\ORM\EntityManager::class],
$di[Adapters::class],
$di[Radio\Adapters::class],
$di[\Supervisor\Supervisor::class]
);
};
$di[Backend\Liquidsoap::class] = function($di) {
return new Backend\Liquidsoap(
$di[Radio\Backend\Liquidsoap::class] = function($di) {
return new Radio\Backend\Liquidsoap(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Supervisor\Supervisor::class],
$di[\Monolog\Logger::class],
$di[\Azura\EventDispatcher::class],
$di[AutoDJ::class]
$di[Radio\AutoDJ::class]
);
};
$di[Backend\None::class] = function($di) {
return new Backend\None(
$di[Radio\Backend\None::class] = function($di) {
return new Radio\Backend\None(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Supervisor\Supervisor::class],
$di[\Monolog\Logger::class],
@ -61,8 +56,16 @@ class RadioProvider implements ServiceProviderInterface
);
};
$di[Frontend\Icecast::class] = function($di) {
return new Frontend\Icecast(
$di[Radio\Filesystem::class] = function($di) {
/** @var \Redis $redis */
$redis = $di[\Redis::class];
$redis->select(5);
return new Radio\Filesystem($redis);
};
$di[Radio\Frontend\Icecast::class] = function($di) {
return new Radio\Frontend\Icecast(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Supervisor\Supervisor::class],
$di[\Monolog\Logger::class],
@ -72,8 +75,8 @@ class RadioProvider implements ServiceProviderInterface
);
};
$di[Frontend\Remote::class] = function($di) {
return new Frontend\Remote(
$di[Radio\Frontend\Remote::class] = function($di) {
return new Radio\Frontend\Remote(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Supervisor\Supervisor::class],
$di[\Monolog\Logger::class],
@ -83,8 +86,8 @@ class RadioProvider implements ServiceProviderInterface
);
};
$di[Frontend\SHOUTcast::class] = function($di) {
return new Frontend\SHOUTcast(
$di[Radio\Frontend\SHOUTcast::class] = function($di) {
return new Radio\Frontend\SHOUTcast(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Supervisor\Supervisor::class],
$di[\Monolog\Logger::class],
@ -94,22 +97,22 @@ class RadioProvider implements ServiceProviderInterface
);
};
$di[Remote\Icecast::class] = function($di) {
return new Remote\Icecast(
$di[Radio\Remote\Icecast::class] = function($di) {
return new Radio\Remote\Icecast(
$di[\GuzzleHttp\Client::class],
$di[\Monolog\Logger::class]
);
};
$di[Remote\SHOUTcast1::class] = function($di) {
return new Remote\SHOUTcast1(
$di[Radio\Remote\SHOUTcast1::class] = function($di) {
return new Radio\Remote\SHOUTcast1(
$di[\GuzzleHttp\Client::class],
$di[\Monolog\Logger::class]
);
};
$di[Remote\SHOUTcast2::class] = function($di) {
return new Remote\SHOUTcast2(
$di[Radio\Remote\SHOUTcast2::class] = function($di) {
return new Radio\Remote\SHOUTcast2(
$di[\GuzzleHttp\Client::class],
$di[\Monolog\Logger::class]
);

View File

@ -20,30 +20,40 @@ class StationsProvider implements ServiceProviderInterface
);
};
$di[Stations\Files\BatchController::class] = function($di) {
return new Stations\Files\BatchController(
$di[\Doctrine\ORM\EntityManager::class],
$di[\App\Radio\Filesystem::class]
);
};
$di[Stations\Files\FilesController::class] = function($di) {
/** @var \Azura\Config $config */
$config = $di[\Azura\Config::class];
return new Stations\Files\FilesController(
$di[\Doctrine\ORM\EntityManager::class],
$di['router'],
$di[\Azura\Cache::class],
$di[\App\Radio\Filesystem::class],
$config->get('forms/rename')
);
};
$di[Stations\Files\ListController::class] = function($di) {
return new Stations\Files\ListController(
$di[\Doctrine\ORM\EntityManager::class],
$di[\App\Radio\Filesystem::class]
);
};
$di[Stations\Files\EditController::class] = function($di) {
/** @var \Azura\Config $config */
$config = $di[\Azura\Config::class];
$router = $di['router'];
return new Stations\Files\EditController(
$di[\Doctrine\ORM\EntityManager::class],
$router,
$di[\Azura\Cache::class],
$di[\App\Radio\Filesystem::class],
$config->get('forms/media', [
'router' => $router,
'router' => $di['router'],
])
);
};

View File

@ -63,7 +63,9 @@ class SyncProvider implements ServiceProviderInterface
$di[Task\Media::class] = function($di) {
return new Task\Media(
$di[\Doctrine\ORM\EntityManager::class]
$di[\Doctrine\ORM\EntityManager::class],
$di[\App\Radio\Filesystem::class],
$di[\Monolog\Logger::class]
);
};

56
src/Radio/Filesystem.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace App\Radio;
use App\Entity;
use App\Flysystem\StationFilesystem;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Cached\CachedAdapter;
use League\Flysystem\Cached\Storage\PhpRedis;
use League\Flysystem\Filesystem as LeagueFilesystem;
/**
* A wrapper and manager class for accessing assets on the filesystem.
*/
class Filesystem
{
/** @var \Redis */
protected $redis;
/** @var StationFilesystem[] All current interfaces managed by this */
protected $interfaces = [];
/**
* @param \Redis $redis
*
* @see \App\Provider\RadioProvider
*/
public function __construct(\Redis $redis)
{
$this->redis = $redis;
}
public function getForStation(Entity\Station $station): StationFilesystem
{
$station_id = $station->getId();
if (!isset($this->interfaces[$station_id])) {
$aliases = [
'media' => $station->getRadioMediaDir(),
'albumart' => $station->getRadioAlbumArtDir(),
'playlists' => $station->getRadioPlaylistsDir(),
'config' => $station->getRadioConfigDir(),
'temp' => $station->getRadioTempDir(),
];
$filesystems = [];
foreach($aliases as $alias => $local_path) {
$adapter = new Local($local_path);
$cached_client = new PhpRedis($this->redis, 'fs_'.$station_id.'_'.$alias, 43200);
$filesystems[$alias] = new LeagueFilesystem(new CachedAdapter($adapter, $cached_client));
}
$this->interfaces[$station_id] = new StationFilesystem($filesystems);
}
return $this->interfaces[$station_id];
}
}

View File

@ -1,9 +1,11 @@
<?php
namespace App\Sync\Task;
use App\Radio\Filesystem;
use Doctrine\Common\Persistence\Mapping\MappingException;
use Doctrine\ORM\EntityManager;
use App\Entity;
use Monolog\Logger;
use Symfony\Component\Finder\Finder;
class Media extends TaskAbstract
@ -11,12 +13,24 @@ class Media extends TaskAbstract
/** @var EntityManager */
protected $em;
/** @var Filesystem */
protected $filesystem;
/** @var Logger */
protected $logger;
/**
* @param EntityManager $em
* @param Filesystem $filesystem
* @param Logger $logger
*
* @see \App\Provider\SyncProvider
*/
public function __construct(EntityManager $em)
public function __construct(EntityManager $em, Filesystem $filesystem, Logger $logger)
{
$this->em = $em;
$this->filesystem = $filesystem;
$this->logger = $logger;
}
public function run($force = false)
@ -31,23 +45,31 @@ class Media extends TaskAbstract
public function importMusic(Entity\Station $station)
{
$base_dir = $station->getRadioMediaDir();
if (empty($base_dir)) {
return;
}
$fs = $this->filesystem->getForStation($station);
$stats = [
'total_files' => 0,
'updated' => 0,
'created' => 0,
'deleted' => 0,
];
$music_files_raw = $this->globDirectory($base_dir);
$music_files = [];
foreach($fs->listContents('media://', true) as $file) {
if ('file' !== $file['type']) {
continue;
}
foreach ($music_files_raw as $music_file_path) {
$path_short = str_replace($base_dir . '/', '', $music_file_path);
$path_short = $file['path'];
$path_hash = md5($path_short);
$music_files[$path_hash] = $path_short;
}
/** @var Entity\Repository\SongRepository $song_repo */
$song_repo = $this->em->getRepository(Entity\Song::class);
$stats['total_files'] = count($music_files);
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
$existing_media_q = $this->em->createQuery('SELECT sm FROM '.Entity\StationMedia::class.' sm WHERE sm.station_id = :station_id')
->setParameter('station_id', $station->getId());
@ -61,30 +83,24 @@ class Media extends TaskAbstract
$media_row = $media_row_iteration[0];
// Check if media file still exists.
$full_path = $base_dir . '/' . $media_row->getPath();
if (file_exists($full_path)) {
$path_hash = md5($media_row->getPath());
if (isset($music_files[$path_hash])) {
$force_reprocess = false;
if (empty($media_row->getUniqueId())) {
$media_row->generateUniqueId();
$force_reprocess = true;
}
// Check for modifications.
$song_info = $media_row->loadFromFile($force_reprocess);
$media_repo->processMedia($media_row, $force_reprocess);
if (is_array($song_info)) {
$media_row->setSong($song_repo->getOrCreate($song_info));
}
$this->em->persist($media_row);
$path_hash = md5($media_row->getPath());
unset($music_files[$path_hash]);
$stats['updated']++;
} else {
// Delete the now-nonexistent media item.
$this->em->remove($media_row);
$stats['deleted']++;
}
// Batch processing
@ -101,14 +117,8 @@ class Media extends TaskAbstract
$i = 0;
foreach ($music_files as $new_file_path) {
$media_row = new Entity\StationMedia($station, $new_file_path);
$song_info = $media_row->loadFromFile();
if (is_array($song_info)) {
$media_row->setSong($song_repo->getOrCreate($song_info));
}
$this->em->persist($media_row);
$media_repo->getOrCreate($station, $new_file_path);
$stats['created']++;
if ($i % $records_per_batch === 0) {
$this->_flushAndClearRecords();
@ -118,6 +128,8 @@ class Media extends TaskAbstract
}
$this->_flushAndClearRecords();
$this->logger->debug(sprintf('Media processed for station "%s".', $station->getName()), $stats);
}
/**
@ -129,7 +141,6 @@ class Media extends TaskAbstract
try {
$this->em->clear(Entity\StationMedia::class);
$this->em->clear(Entity\StationMediaArt::class);
$this->em->clear(Entity\Song::class);
} catch (MappingException $e) {}
}

View File

@ -22,8 +22,12 @@ class C05_Station_AutomationCest extends CestAbstract
$this->em->persist($playlist);
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
$media = new Entity\StationMedia($this->test_station, 'test.mp3');
$media->loadFromFile();
$media_repo->loadFromFile($media, $song_dest);
$this->em->persist($media);
$spm = new Entity\StationPlaylistMedia($playlist, $media);

View File

@ -36,11 +36,7 @@ abstract class CestAbstract
/** @var Entity\Repository\StationRepository $station_repo */
$station_repo = $this->em->getRepository(Entity\Station::class);
$this->test_station = $station_repo->destroy(
$this->test_station,
$this->di[\App\Radio\Adapters::class],
$this->di[\App\Radio\Configuration::class]
);
$this->test_station = $station_repo->destroy($this->test_station);
}
}
@ -102,11 +98,7 @@ abstract class CestAbstract
/** @var Entity\Repository\StationRepository $station_repo */
$station_repo = $this->em->getRepository(Entity\Station::class);
$this->test_station = $station_repo->create(
$station_info,
$this->di[\App\Radio\Adapters::class],
$this->di[\App\Radio\Configuration::class]
);
$this->test_station = $station_repo->create($station_info);
// Set settings.

View File

@ -25,8 +25,12 @@ class D02_Api_RequestsCest extends CestAbstract
$this->em->persist($playlist);
/** @var Entity\Repository\StationMediaRepository $media_repo */
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
$media = new Entity\StationMedia($this->test_station, 'test.mp3');
$media->loadFromFile();
$media_repo->loadFromFile($media, $song_dest);
$this->em->persist($media);
$spm = new Entity\StationPlaylistMedia($playlist, $media);