CC-5450 : Refactor Media Management
refactored webstreams, new/save is working adding validators/filters for the url separating out parsing of different webstream formats.
This commit is contained in:
parent
01d48e7e79
commit
19a7e7eb5d
|
@ -186,5 +186,15 @@ class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
|
|||
'action' => 'password-change',
|
||||
)));
|
||||
}
|
||||
|
||||
protected function _initAutoload () {
|
||||
|
||||
// configure new autoloader
|
||||
$autoloader = new Zend_Application_Module_Autoloader (array ('namespace' => '', 'basePath' => APPLICATION_PATH));
|
||||
|
||||
// autoload form validators & filters definition
|
||||
$autoloader->addResourceType ('Filter', 'forms/filters', 'Filter_');
|
||||
$autoloader->addResourceType ('Validator', 'forms/validators', 'Validate_');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,22 @@ class WebstreamController extends Zend_Controller_Action
|
|||
|
||||
public function newAction()
|
||||
{
|
||||
//clear the session in case an old playlist was open: CC-4196
|
||||
Application_Model_Library::changePlaylist(null, null);
|
||||
|
||||
$service = new Application_Service_WebstreamService();
|
||||
$form = $service->makeWebstreamForm(null);
|
||||
|
||||
$form->setDefaults(array(
|
||||
'name' => 'Unititled Webstream',
|
||||
'hours' => 0,
|
||||
'mins' => 30,
|
||||
));
|
||||
|
||||
$this->view->action = "new";
|
||||
$this->view->html = $form->render();
|
||||
|
||||
/*
|
||||
$userInfo = Zend_Auth::getInstance()->getStorage()->read();
|
||||
if (!$this->isAuthorized(-1)) {
|
||||
// TODO: this header call does not actually print any error message
|
||||
|
@ -44,6 +59,7 @@ class WebstreamController extends Zend_Controller_Action
|
|||
$this->view->obj = new Application_Model_Webstream($webstream);
|
||||
$this->view->action = "new";
|
||||
$this->view->html = $this->view->render('webstream/webstream.phtml');
|
||||
*/
|
||||
}
|
||||
|
||||
public function editAction()
|
||||
|
@ -117,20 +133,40 @@ class WebstreamController extends Zend_Controller_Action
|
|||
public function saveAction()
|
||||
{
|
||||
$request = $this->getRequest();
|
||||
|
||||
$id = $request->getParam("id");
|
||||
|
||||
$parameters = array();
|
||||
foreach (array('id','length','name','description','url') as $p) {
|
||||
|
||||
foreach (array('id','hours', 'mins', 'name','description','url') as $p) {
|
||||
$parameters[$p] = trim($request->getParam($p));
|
||||
}
|
||||
|
||||
Logging::info($parameters);
|
||||
|
||||
$service = new Application_Service_WebstreamService();
|
||||
$form = $service->makeWebstreamForm(null);
|
||||
|
||||
if ($form->isValid($parameters)) {
|
||||
Logging::info("form is valid");
|
||||
|
||||
$values = $form->getValues();
|
||||
Logging::info($values);
|
||||
|
||||
$service->saveWebstream($values);
|
||||
}
|
||||
else {
|
||||
Logging::info("form is not valid");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
if (!$this->isAuthorized($id)) {
|
||||
header("Status: 401 Not Authorized");
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
list($analysis, $mime, $mediaUrl, $di) = Application_Model_Webstream::analyzeFormData($parameters);
|
||||
try {
|
||||
if (Application_Model_Webstream::isValid($analysis)) {
|
||||
|
@ -150,5 +186,6 @@ class WebstreamController extends Zend_Controller_Action
|
|||
$this->view->streamId = -1;
|
||||
$this->view->analysis = $analysis;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
class Application_Form_Webstream extends Zend_Form
|
||||
{
|
||||
public function init() {
|
||||
|
||||
$this->setDecorators(
|
||||
array(
|
||||
array('ViewScript', array('viewScript' => 'form/webstream.phtml'))
|
||||
)
|
||||
);
|
||||
|
||||
$id = new Zend_Form_Element_Hidden('id');
|
||||
$id->setValidators(array(
|
||||
new Zend_Validate_Int()
|
||||
));
|
||||
$this->addElement($id);
|
||||
|
||||
$name = new Zend_Form_Element_Text('name');
|
||||
$name->setLabel(_('Firstname:'));
|
||||
$name->setAttrib('class', 'input_text');
|
||||
$name->addFilter('StringTrim');
|
||||
$this->addElement($name);
|
||||
|
||||
$description = new Zend_Form_Element_Text('description');
|
||||
$description->setLabel(_('Description:'));
|
||||
$description->setAttrib('class', 'input_text_area');
|
||||
$description->addFilter('StringTrim');
|
||||
$description->setValidators(array(
|
||||
new Zend_Validate_StringLength(array('max' => 512)),
|
||||
));
|
||||
$this->addElement($description);
|
||||
|
||||
$url = new Zend_Form_Element_Text('url');
|
||||
$url->setLabel(_('Stream URL:'));
|
||||
$url->setAttrib('class', 'input_text');
|
||||
$url->addFilter('StringTrim');
|
||||
$url->addFilter(new Filter_WebstreamRedirect());
|
||||
$url->setValidators(array(
|
||||
new Validate_WebstreamUrl(),
|
||||
));
|
||||
$this->addElement($url);
|
||||
|
||||
$hours = new Zend_Form_Element_Text('hours');
|
||||
$hours->setLabel(_('h'));
|
||||
$hours->setAttrib('class', 'input_text');
|
||||
$hours->addFilter('StringTrim');
|
||||
$hours->setValidators(array(
|
||||
new Zend_Validate_Int(),
|
||||
));
|
||||
$this->addElement($hours);
|
||||
|
||||
$min = new Zend_Form_Element_Text('mins');
|
||||
$min->setLabel(_('m'));
|
||||
$min->setAttrib('class', 'input_text');
|
||||
$min->addFilter('StringTrim');
|
||||
$min->setValidators(array(
|
||||
new Zend_Validate_Int(),
|
||||
));
|
||||
$this->addElement($min);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
class Filter_WebstreamRedirect implements Zend_Filter_Interface
|
||||
{
|
||||
public function filter($value)
|
||||
{
|
||||
Logging::info("checking if $value passes filter");
|
||||
|
||||
//empty when first creating the form.
|
||||
if (empty($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// By default get_headers uses a GET request to fetch the headers. If you
|
||||
// want to send a HEAD request instead, you can do so using a stream context:
|
||||
//using max redirects to avoid mixed headers,
|
||||
//can manually follow redirects if a Location header exists.
|
||||
stream_context_set_default(
|
||||
array(
|
||||
'http' => array(
|
||||
'method' => 'HEAD',
|
||||
'max_redirects' => '1'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
$url = $value;
|
||||
|
||||
while (true) {
|
||||
//get an associative array of headers.
|
||||
$headers = get_headers($url, 1);
|
||||
Logging::info($headers);
|
||||
|
||||
if (empty($headers["Location"])) {
|
||||
//is not a redirect;
|
||||
break;
|
||||
}
|
||||
else {
|
||||
$url = $headers["Location"];
|
||||
}
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
catch (Exception $e) {
|
||||
throw new Zend_Filter_Exception( _("Invalid webstream url"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
class Validate_WebstreamUrl extends Zend_Validate_Abstract
|
||||
{
|
||||
const INVALID_WEBSTREAM_URL = 'webstream_url';
|
||||
const INVALID_MIME_TYPE = 'webstream_mime';
|
||||
|
||||
protected $_messageTemplates;
|
||||
|
||||
protected $_validMimeTypePatterns = array(
|
||||
"/x-mpegurl/" => false,
|
||||
"/xspf\+xml/" => false,
|
||||
"/pls\+xml|x-scpls/" => false,
|
||||
"/(mpeg|ogg|audio\/aacp)/" => true
|
||||
);
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->_messageTemplates = array(
|
||||
self::INVALID_WEBSTREAM_URL => _("Invalid webstream url"),
|
||||
self::INVALID_MIME_TYPE => _("Invalid webstream mime type"),
|
||||
);
|
||||
}
|
||||
|
||||
private function validateNoContentLength($headers)
|
||||
{
|
||||
return isset($headers["Content-Length"]) ? false : true;
|
||||
}
|
||||
|
||||
public function isValid($value)
|
||||
{
|
||||
Logging::info("checking if $value is valid");
|
||||
|
||||
// By default get_headers uses a GET request to fetch the headers. If you
|
||||
// want to send a HEAD request instead, you can do so using a stream context:
|
||||
//using max redirects to avoid mixed headers,
|
||||
//can manually follow redirects if a Location header exists.
|
||||
stream_context_set_default(
|
||||
array(
|
||||
'http' => array(
|
||||
'method' => 'HEAD'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
//get an associative array of headers.
|
||||
$headers = get_headers($value, 1);
|
||||
|
||||
if (empty($headers["Content-Type"])) {
|
||||
$this->_error(self::INVALID_MIME_TYPE);
|
||||
return false;
|
||||
}
|
||||
|
||||
$contentType = $headers["Content-Type"];
|
||||
Logging::info($contentType);
|
||||
|
||||
$isValid = false;
|
||||
|
||||
foreach ($this->_validMimeTypePatterns as $pattern => $doContentLengthCheck) {
|
||||
|
||||
if (preg_match($pattern, $contentType)) {
|
||||
|
||||
if ($doContentLengthCheck) {
|
||||
$isValid = self::validateNoContentLength($headers);
|
||||
}
|
||||
else {
|
||||
$isValid = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isValid === false) {
|
||||
$this->_error(self::INVALID_MIME_TYPE);
|
||||
}
|
||||
|
||||
return $isValid;
|
||||
}
|
||||
catch (Exception $e) {
|
||||
//url is not real
|
||||
$this->_error(self::INVALID_WEBSTREAM_URL);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,4 +18,40 @@ use Airtime\MediaItem\om\BaseWebstream;
|
|||
*/
|
||||
class Webstream extends BaseWebstream
|
||||
{
|
||||
public function getHoursMins() {
|
||||
|
||||
return explode(":", $this->getLength());
|
||||
}
|
||||
|
||||
public function getUrlData() {
|
||||
|
||||
$url = $this->getUrl();
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_HEADER, 0);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
|
||||
// grab URL and pass it to the browser
|
||||
//TODO: What if invalid url?
|
||||
$content = curl_exec($ch);
|
||||
|
||||
// close cURL resource, and free up system resources
|
||||
curl_close($ch);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function setUrl($v) {
|
||||
|
||||
parent::setUrl($v);
|
||||
|
||||
//get an associative array of headers.
|
||||
$headers = get_headers($v, 1);
|
||||
$mime = $headers["Content-Type"];
|
||||
|
||||
$this->setMime($mime);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace Airtime\MediaItem\Webstream;
|
||||
|
||||
use \Exception;
|
||||
use \Logging;
|
||||
|
||||
class M3UWebstreamParser implements WebstreamParser {
|
||||
|
||||
public function getStreamUrl($webstream) {
|
||||
|
||||
try {
|
||||
$content = $webstream->getUrlData();
|
||||
|
||||
//split into lines:
|
||||
$delim = "\n";
|
||||
if (strpos($content, "\r\n") !== false) {
|
||||
$delim = "\r\n";
|
||||
}
|
||||
|
||||
$lines = explode($delim, $content);
|
||||
|
||||
//TODO test the stream
|
||||
return $lines[0];
|
||||
|
||||
}
|
||||
catch (Exception $e) {
|
||||
Logging::warn($e->getMessage());
|
||||
throw new Exception(_("Could not parse M3U playlist"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Airtime\MediaItem\Webstream;
|
||||
|
||||
use \Exception;
|
||||
use \Logging;
|
||||
|
||||
class PLSWebstreamParser implements WebstreamParser {
|
||||
|
||||
public function getStreamUrl($webstream) {
|
||||
|
||||
try {
|
||||
|
||||
$content = $webstream->getUrlData();
|
||||
$ini = parse_ini_string($content, true);
|
||||
|
||||
//TODO test the stream
|
||||
return $ini["playlist"]["File1"];
|
||||
}
|
||||
catch (Exception $e) {
|
||||
Logging::warn($e->getMessage());
|
||||
throw new Exception(_("Could not parse PLS playlist"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace Airtime\MediaItem\Webstream;
|
||||
|
||||
interface WebstreamParser {
|
||||
|
||||
//parses a m3u, pls, xspf etc format to get a valid stream url
|
||||
public function getStreamUrl($webstream);
|
||||
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Airtime\MediaItem\Webstream;
|
||||
|
||||
use \Exception;
|
||||
use \Logging;
|
||||
|
||||
class XSPFWebstreamParser implements WebstreamParser {
|
||||
|
||||
public function getStreamUrl($webstream) {
|
||||
|
||||
try {
|
||||
|
||||
$content = $webstream->getUrlData();
|
||||
|
||||
$dom = new DOMDocument;
|
||||
|
||||
$dom->loadXML($content);
|
||||
$tracks = $dom->getElementsByTagName('track');
|
||||
|
||||
foreach ($tracks as $track) {
|
||||
$locations = $track->getElementsByTagName('location');
|
||||
foreach ($locations as $loc) {
|
||||
return $loc->nodeValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception $e) {
|
||||
Logging::warn($e->getMessage());
|
||||
throw new Exception(_("Could not parse XSPF playlist"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
use Airtime\MediaItem\Webstream;
|
||||
|
||||
use Airtime\MediaItem\WebstreamQuery;
|
||||
|
||||
class Application_Service_WebstreamService
|
||||
{
|
||||
public function makeWebstreamForm($id, $populate = false) {
|
||||
|
||||
try {
|
||||
$form = new Application_Form_Webstream();
|
||||
|
||||
if ($populate) {
|
||||
|
||||
$webstream = WebstreamQuery::create()->findPk($id);
|
||||
$length = $webstream->getHoursMins();
|
||||
|
||||
$formValues = array(
|
||||
"name" => $webstream->getName(),
|
||||
"description" => $webstream->getDescription(),
|
||||
"url" => $webstream->getUrl(),
|
||||
"hours" => $length[0],
|
||||
"mins" => $length[1]
|
||||
);
|
||||
|
||||
$form->populate($formValues);
|
||||
}
|
||||
|
||||
return $form;
|
||||
}
|
||||
catch (Exception $e) {
|
||||
Logging::info($e);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildFromFormValues($ws, $values) {
|
||||
|
||||
$hours = intval($values["hours"]);
|
||||
$minutes = intval($values["mins"]);
|
||||
|
||||
if ($minutes > 59) {
|
||||
//minutes cannot be over 59. Need to convert anything > 59 minutes into hours.
|
||||
$hours += intval($minutes/60);
|
||||
$minutes = $minutes%60;
|
||||
}
|
||||
|
||||
$length = "$hours:$minutes";
|
||||
|
||||
$ws->setName($values["name"]);
|
||||
$ws->setDescription($values["description"]);
|
||||
$ws->setUrl($values["url"]);
|
||||
$ws->setLength($length);
|
||||
|
||||
return $ws;
|
||||
}
|
||||
|
||||
public function createWebstream($values) {
|
||||
|
||||
$ws = new Webstream();
|
||||
$ws = self::buildFromFormValues($ws, $values);
|
||||
|
||||
return $ws;
|
||||
}
|
||||
|
||||
public function updateWebstream($values) {
|
||||
|
||||
$ws = WebstreamQuery::create()->findPk($values["id"]);
|
||||
$ws = self::buildFromFormValues($ws, $values);
|
||||
|
||||
return $ws;
|
||||
}
|
||||
|
||||
public function saveWebstream($values) {
|
||||
|
||||
if ($values["id"] != null) {
|
||||
$ws = self::updateWebstream($values);
|
||||
}
|
||||
else {
|
||||
$ws = self::createWebstream($values);
|
||||
}
|
||||
$ws->save();
|
||||
|
||||
return $ws;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
<?php $form = $this->element ?>
|
||||
|
||||
<a href="#" class="close-round" id="lib_pl_close"></a>
|
||||
<div class="btn-toolbar spl-no-top-margin clearfix">
|
||||
<div class="btn-group pull-left">
|
||||
<button id="ws_new" class="btn dropdown-toggle" data-toggle="dropdown" aria-disabled="false">
|
||||
<?php echo _("New")?> <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li id='lib-new-pl'><a href="#"><?php echo _("New Playlist") ?></a></li>
|
||||
<li id='lib-new-bl'><a href="#"><?php echo _("New Smart Block") ?></a></li>
|
||||
<li id='lib-new-ws'><a href="#"><?php echo _("New Webstream") ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<?php if (isset($form)) : ?>
|
||||
<div class="btn-group pull-right">
|
||||
<button class="btn btn-inverse" type="submit" id="webstream_save" name="submit"><?php echo _("Save") ?></button>
|
||||
</div>
|
||||
<div class="btn-group pull-right">
|
||||
<button id="ws_delete" class="btn" <?php if ($this->action == "new"): ?>style="display:none;"<?php endif; ?>aria-disabled="false"><?php echo _("Delete") ?></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (isset($form)) : ?>
|
||||
<input id="ws_id" type="hidden" value="<?php echo $form->getElement("id")->getValue() ?>"></input>
|
||||
<input id="obj_lastMod" type="hidden" value="<?php echo "1";//$this->obj->getLastModified('U'); ?>"></input>
|
||||
<div class="status" style="display:none;"></div>
|
||||
|
||||
<div class="playlist_title">
|
||||
<h3>
|
||||
<a id="ws_name" contenteditable="true"><?php echo $form->getElement("name")->getValue() ?></a>
|
||||
</h3>
|
||||
<h4 id="ws_length">00h 00m</h4>
|
||||
</div>
|
||||
|
||||
<fieldset class="toggle" id="fieldset-metadate_change">
|
||||
<legend style="cursor: pointer;"><span class="ui-icon ui-icon-triangle-2-n-s"></span><?php echo _("View / edit description"); ?></legend>
|
||||
<dl class="zend_form">
|
||||
<dt>
|
||||
<label><?php echo _("Description") ?></label>
|
||||
</dt>
|
||||
<dd>
|
||||
<textarea cols="80" rows="24" id="ws_description"><?php echo $form->getElement("description")->getValue() ?></textarea>
|
||||
</dd>
|
||||
</dl>
|
||||
</fieldset>
|
||||
|
||||
<dl class="zend_form">
|
||||
<dt>
|
||||
<label><?php echo _("Stream URL:"); ?></label>
|
||||
</dt>
|
||||
<dd>
|
||||
<input id="ws_url" type="text" value="<?php echo $form->getElement("url")->getValue() ?>" size="60"/>
|
||||
</dd>
|
||||
|
||||
<dt>
|
||||
<label><?php echo _("Default Length:"); ?></label>
|
||||
</dt>
|
||||
<dd>
|
||||
<input id="ws_hours" type="text" value="<?php echo $form->getElement("hours")->getValue() ?>" size="2"/><?php echo _("h"); ?>
|
||||
<input id="ws_mins" type="text" value="<?php echo $form->getElement("mins")->getValue() ?>" size="2"/><?php echo _("m"); ?>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<?php else : ?>
|
||||
<div><?php echo _("No webstream") ?></div>
|
||||
<?php endif; ?>
|
|
@ -780,27 +780,37 @@ var AIRTIME = (function(AIRTIME){
|
|||
disableLoadingIcon();
|
||||
setTimeout(removeSuccessMsg, 5000);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
$pl.on("click", "#webstream_save", function(){
|
||||
$pl.on("click", "#webstream_save", function() {
|
||||
//get all fields and POST to server
|
||||
//description
|
||||
//stream url
|
||||
//default_length
|
||||
//playlist name
|
||||
var id = $pl.find("#obj_id").attr("value");
|
||||
var description = $pl.find("#description").val();
|
||||
var streamurl = $pl.find("#streamurl-element input").val();
|
||||
var length = $pl.find("#streamlength-element input").val();
|
||||
var name = $pl.find("#playlist_name_display").text();
|
||||
var id = $pl.find("#ws_id").attr("value");
|
||||
var description = $pl.find("#ws_description").val();
|
||||
var streamurl = $pl.find("#ws_url").val();
|
||||
var hours = $pl.find("#ws_hours").val();
|
||||
var mins = $pl.find("#ws_mins").val();
|
||||
var name = $pl.find("#ws_name").text();
|
||||
|
||||
var parameters = {
|
||||
format: "json",
|
||||
description: description,
|
||||
url: streamurl,
|
||||
hours: hours,
|
||||
mins: mins,
|
||||
name: name
|
||||
};
|
||||
|
||||
if (id !== "") {
|
||||
parameters["id"] = id;
|
||||
}
|
||||
|
||||
//hide any previous errors (if any)
|
||||
$("#side_playlist .errors").empty().hide();
|
||||
|
||||
var url = baseUrl+'Webstream/save';
|
||||
$.post(url,
|
||||
{format: "json", id:id, description: description, url:streamurl, length: length, name: name},
|
||||
function(json){
|
||||
parameters,
|
||||
function(json) {
|
||||
if (json.analysis){
|
||||
for (var s in json.analysis){
|
||||
var field = json.analysis[s];
|
||||
|
@ -810,7 +820,8 @@ var AIRTIME = (function(AIRTIME){
|
|||
var $div = $("#side_playlist " + elemId).text(field[1]).show();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
var $status = $("#side_playlist .status");
|
||||
$status.html(json.statusMessage);
|
||||
$status.show();
|
||||
|
@ -828,11 +839,8 @@ var AIRTIME = (function(AIRTIME){
|
|||
|
||||
//redraw the library to show the new webstream
|
||||
redrawLib();
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
$lib.on("click", "#pl_edit", function() {
|
||||
|
|
Loading…
Reference in New Issue