* 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:
parent
bb30feeee4
commit
70914a67c1
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -3,7 +3,7 @@ return [
|
|||
'method' => 'post',
|
||||
'elements' => [
|
||||
|
||||
'path' => [
|
||||
'new_file' => [
|
||||
'text',
|
||||
[
|
||||
'label' => __('File Name'),
|
||||
|
@ -21,4 +21,4 @@ return [
|
|||
],
|
||||
|
||||
],
|
||||
];
|
||||
];
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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:') ?>
|
||||
<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">×</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">×</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">×</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">×</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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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]]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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'],
|
||||
])
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in New Issue