mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-09-12 21:27:39 -04:00
database.php called dbConnect() at file scope, so including it opened a socket,
and on failure rendered views/no_database_connection.php and exit()ed from
inside a library include. Every model in web/includes requires this file, so
merely loading a class did both.
Connect on first use instead. $dbConn becomes tri-state - false for "not
attempted", null for "attempt failed", a PDO for connected - and two accessors
sit on top of it:
zmDbConn() opens if needed; on failure renders the error view and
stops, which is what the include used to do, just at the
point a query is actually attempted.
zmDbConnOrNull() opens if needed but returns null instead of ending the
request, for callers with a fallback.
dbQuery() is the funnel every fetch helper goes through, so routing it plus
dbEscape(), dbError() and dbInsertId() through the accessors covers the library.
The five callers that reached for the raw global are updated: config.php.in,
Event.php and ajax/console.php need a connection and take zmDbConn(); logger.php
takes zmDbConnOrNull() and falls through to its error_log target, so a logging
call can no longer end the request or open a connection by itself.
ZMSessionHandler captured $dbConn in its constructor. It is constructed while
session.php is being included, before anything has needed the database, so with
a lazy connection that captured false. It now resolves per call and its methods
return "no session" rather than dereferencing a bool.
Two smaller fixes fall out. The error view was included by a relative path that
only resolved when the cwd was web/, so it never worked for requests served out
of web/api/; it is now anchored with __DIR__. And dbDisconnect() set $dbConn to
null, which in the new tri-state means "connecting failed" and would send the
next query to the error page; it sets false so a later query can reconnect.
Nothing calls dbDisconnect() today.
This does NOT make database.php includable without a database. It requires
logger.php, which requires config.php, which reads ZoneMinder's configuration
out of the Config table at include time. Until that cycle is broken the
connection still happens during bootstrap, just from config.php rather than from
here.
Tests: tests/php/test_database_lazy_connect.php, 7 assertions, all pass. It
tokenises database.php and asserts nothing runs at include time, that dbQuery()
goes through the accessor, and that only the connection plumbing touches the
global. Verified it reports the pre-refactor file's `if ( !dbConnect() )` - an
earlier version of the check skipped tokens inside parentheses and so passed on
exactly the code it exists to reject.
Not covered by tests: behaviour when the database is genuinely unreachable, and
the session handler against a live database. Needs manual testing on an
installed tree, including stopping mysql to confirm the error view still renders
for both a web request and an API request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
894 lines
32 KiB
PHP
894 lines
32 KiB
PHP
<?php
|
|
namespace ZM;
|
|
require_once('Storage.php');
|
|
require_once('functions.php');
|
|
require_once('Object.php');
|
|
require_once('Event_Tag.php');
|
|
require_once('Tag.php');
|
|
|
|
class Event extends ZM_Object {
|
|
protected static $table = 'Events';
|
|
|
|
protected $Tags;
|
|
protected $Event_Tags;
|
|
|
|
protected $defaults = array(
|
|
'Id' => null,
|
|
'Name' => '',
|
|
'MonitorId' => null,
|
|
'StorageId' => null,
|
|
'SecondaryStorageId' => null,
|
|
'Cause' => '',
|
|
'StartDateTime' => null,
|
|
'EndDateTime' => null,
|
|
'Width' => null,
|
|
'Height' => null,
|
|
'Length' => null,
|
|
'Frames' => null,
|
|
'AlarmFrames' => null,
|
|
'DefaultVideo' => '',
|
|
'SaveJPEGs' => 0,
|
|
'TotScore' => 0,
|
|
'AvgScore' => 0,
|
|
'MaxScore' => 0,
|
|
'Archived' => 0,
|
|
'Videoed' => 0,
|
|
'Uploaded' => 0,
|
|
'Emailed' => 0,
|
|
'Messaged' => 0,
|
|
'Executed' => 0,
|
|
'Notes' => '',
|
|
'StateId' => 0,
|
|
'Orientation' => 0,
|
|
'DiskSpace' => null,
|
|
'Scheme' => 0,
|
|
'Locked' => 0,
|
|
);
|
|
public static function find( $parameters = array(), $options = array() ) {
|
|
return ZM_Object::_find(self::class, $parameters, $options);
|
|
}
|
|
|
|
public static function find_one( $parameters = array(), $options = array() ) {
|
|
return ZM_Object::_find_one(self::class, $parameters, $options);
|
|
}
|
|
|
|
public static function clear_cache() {
|
|
return ZM_Object::_clear_cache(self::class);
|
|
}
|
|
public function remove_from_cache() {
|
|
return ZM_Object::_remove_from_cache(self::class, $this);
|
|
}
|
|
|
|
public function Storage( $new = null ) {
|
|
if ( $new ) {
|
|
$this->{'Storage'} = $new;
|
|
}
|
|
if ( ! ( property_exists($this, 'Storage') and $this->{'Storage'} ) ) {
|
|
if ( isset($this->{'StorageId'}) and $this->{'StorageId'} )
|
|
$this->{'Storage'} = Storage::find_one(array('Id'=>$this->{'StorageId'}));
|
|
if ( ! ( property_exists($this, 'Storage') and $this->{'Storage'} ) ) {
|
|
$this->{'Storage'} = new Storage(NULL);
|
|
$this->{'Storage'}->Scheme($this->Scheme());
|
|
}
|
|
}
|
|
return $this->{'Storage'};
|
|
}
|
|
|
|
public function SecondaryStorage( $new = null ) {
|
|
if ( $new ) {
|
|
$this->{'SecondaryStorage'} = $new;
|
|
}
|
|
if ( ! ( property_exists($this, 'SecondaryStorage') and $this->{'SecondaryStorage'} ) ) {
|
|
if ( isset($this->{'SecondaryStorageId'}) and $this->{'SecondaryStorageId'} )
|
|
$this->{'SecondaryStorage'} = Storage::find_one(array('Id'=>$this->{'SecondaryStorageId'}));
|
|
if ( ! ( property_exists($this, 'SecondaryStorage') and $this->{'SecondaryStorage'} ) )
|
|
$this->{'SecondaryStorage'} = new Storage(NULL);
|
|
}
|
|
return $this->{'SecondaryStorage'};
|
|
}
|
|
|
|
private function GetFileDuration( $file ) {
|
|
$duration = 0;
|
|
if ( $file && file_exists($file) && defined('ZM_PATH_FFMPEG') && ZM_PATH_FFMPEG ) {
|
|
$ffmpeg = ZM_PATH_FFMPEG;
|
|
$ffprobe = preg_replace('/ffmpeg(\.exe)?$/i', 'ffprobe$1', $ffmpeg);
|
|
|
|
if ( $ffprobe && is_executable($ffprobe) ) {
|
|
$command = escapeshellarg($ffprobe)
|
|
. ' -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '
|
|
. escapeshellarg($file) . ' 2>&1';
|
|
$output = shell_exec($command);
|
|
if ( is_string($output) ) {
|
|
$output = trim($output);
|
|
if ( $output !== '' && is_numeric($output) ) {
|
|
$duration = (float)$output;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $duration;
|
|
}
|
|
|
|
public function Length(){
|
|
$duration = 0;
|
|
if ( !isset($this->{'Length'}) || (float)$this->{'Length'} <= 0 ) {
|
|
$files = glob($this->Path().'{/incomplete.*,/'.$this->{'Id'}.'-video.*}', GLOB_NOSORT | GLOB_BRACE);
|
|
if (count($files) > 0) {
|
|
$duration = $this->GetFileDuration($files[0]);
|
|
} else {
|
|
//TODO: IgorA100 Something needs to be done, but what exactly?
|
|
//$duration = $this->EndDateTimeSecs() - $this->StartDateTimeSecs();
|
|
}
|
|
} else {
|
|
$duration = $this->{'Length'};
|
|
}
|
|
return $duration;
|
|
}
|
|
|
|
public function Frames(){
|
|
if(! isset($this->{'Frames'})){
|
|
//TOOD: Do something when no Frames found
|
|
}
|
|
return $this->{'Frames'};
|
|
}
|
|
|
|
public function Monitor() {
|
|
if ( isset($this->{'MonitorId'}) ) {
|
|
$Monitor = Monitor::find_one(array('Id'=>$this->{'MonitorId'}));
|
|
if ( $Monitor )
|
|
return $Monitor;
|
|
}
|
|
return new Monitor();
|
|
}
|
|
|
|
public function Time() {
|
|
if ( ! isset($this->{'Time'}) ) {
|
|
$this->{'Time'} = strtotime($this->{'StartDateTime'});
|
|
}
|
|
return $this->{'Time'};
|
|
}
|
|
|
|
public function StartDateTimeSecs() {
|
|
return strtotime($this->{'StartDateTime'});
|
|
}
|
|
|
|
public function EndDateTimeSecs() {
|
|
return strtotime($this->{'EndDateTime'});
|
|
}
|
|
|
|
public function Duration() {
|
|
return $this->Length();
|
|
}
|
|
|
|
public function Path() {
|
|
$Storage = $this->Storage();
|
|
if ( $Storage->Path() and $this->Relative_Path() ) {
|
|
return $Storage->Path().'/'.$this->Relative_Path();
|
|
} else {
|
|
Error('Event Path not complete. Storage: '.$Storage->Path().' relative: '.$this->Relative_Path());
|
|
return '';
|
|
}
|
|
}
|
|
|
|
public function Relative_Path() {
|
|
$event_path = '';
|
|
|
|
if ( $this->{'Scheme'} == 'Deep' ) {
|
|
$event_path = $this->{'MonitorId'}.'/'.date('y/m/d/H/i/s', $this->Time());
|
|
} else if ( $this->{'Scheme'} == 'Medium' ) {
|
|
$event_path = $this->{'MonitorId'}.'/'.date('Y-m-d', $this->Time()).'/'.$this->{'Id'};
|
|
} else {
|
|
$event_path = $this->{'MonitorId'}.'/'.$this->{'Id'};
|
|
}
|
|
|
|
return $event_path;
|
|
} // end function Relative_Path()
|
|
|
|
public function Link_Path() {
|
|
if ( $this->{'Scheme'} == 'Deep' ) {
|
|
return $this->{'MonitorId'}.'/'.date('y/m/d/.', $this->Time()).$this->{'Id'};
|
|
}
|
|
Error('Calling Link_Path when not using deep storage');
|
|
return '';
|
|
}
|
|
|
|
public function delete() {
|
|
if ( ! $this->{'Id'} ) {
|
|
Error('Event delete on event with empty Id');
|
|
return;
|
|
}
|
|
if ( $this->{'Archived'} ) {
|
|
Error('Cannot delete an Archived event.');
|
|
return;
|
|
}
|
|
if ( ZM_OPT_FAST_DELETE ) {
|
|
dbQuery('DELETE FROM Events WHERE Id = ?', array($this->{'Id'}));
|
|
return;
|
|
}
|
|
|
|
$dbConn = zmDbConn();
|
|
$dbConn->beginTransaction();
|
|
try {
|
|
$this->lock();
|
|
dbQuery('DELETE FROM Stats WHERE EventId = ?', array($this->{'Id'}));
|
|
dbQuery('DELETE FROM Frames WHERE EventId = ?', array($this->{'Id'}));
|
|
if ( $this->{'Scheme'} == 'Deep' ) {
|
|
|
|
# Assumption: All events have a start time
|
|
$start_date = date_parse($this->{'StartDateTime'});
|
|
if ( ! $start_date ) {
|
|
throw new Exception('Unable to parse start date time for event ' . $this->{'Id'} . ' not deleting files.');
|
|
}
|
|
$start_date['year'] = $start_date['year'] % 100;
|
|
|
|
# So this is because ZM creates a link under the day pointing to the time that the event happened.
|
|
$link_path = $this->Link_Path();
|
|
if ( ! $link_path ) {
|
|
throw new Exception('Unable to determine link path for event '.$this->{'Id'}.' not deleting files.');
|
|
}
|
|
|
|
$Storage = $this->Storage();
|
|
$eventlink_path = $Storage->Path().'/'.$link_path;
|
|
|
|
if ( $id_files = glob($eventlink_path) ) {
|
|
if ( ! $eventPath = readlink($id_files[0]) ) {
|
|
throw new Exception("Unable to read link at $id_files[0]");
|
|
}
|
|
# I know we are using arrays here, but really there can only ever be 1 in the array
|
|
$eventPath = preg_replace('/\.'.$this->{'Id'}.'$/', $eventPath, $id_files[0]);
|
|
deletePath($eventPath);
|
|
deletePath($id_files[0]);
|
|
$pathParts = explode('/', $eventPath);
|
|
for ( $i = count($pathParts)-1; $i >= 2; $i-- ) {
|
|
$deletePath = join('/', array_slice($pathParts, 0, $i));
|
|
if ( !glob($deletePath.'/*') ) {
|
|
deletePath($deletePath);
|
|
}
|
|
}
|
|
} else {
|
|
Warning("Found no event files under $eventlink_path");
|
|
} # end if found files
|
|
} else {
|
|
$eventPath = $this->Path();
|
|
if ( ! $eventPath ) {
|
|
throw new Exception('No event Path in Event delete. Not deleting');
|
|
}
|
|
deletePath($eventPath);
|
|
if ( $this->SecondaryStorageId() ) {
|
|
$Storage = $this->SecondaryStorage();
|
|
if ( $Storage->Id() ) {
|
|
$eventPath = $Storage->Path().'/'.$this->Relative_Path();
|
|
if ( ! $eventPath ) {
|
|
Error('No event Path in Event delete. Not deleting');
|
|
} else {
|
|
deletePath($eventPath);
|
|
}
|
|
} # end if Storage
|
|
} # end if has Secondary Storage
|
|
} # USE_DEEP_STORAGE OR NOT
|
|
dbQuery('DELETE FROM Events WHERE Id = ?', array($this->{'Id'}));
|
|
$dbConn->commit();
|
|
} catch (PDOException $e) {
|
|
$dbConn->rollback();
|
|
} catch (Exception $e) {
|
|
Error($e->getMessage());
|
|
$dbConn->rollback();
|
|
}
|
|
} # end Event->delete
|
|
|
|
public function Server() {
|
|
if ( $this->Storage()->ServerId() ) {
|
|
# The Event may have been moved to Storage on another server,
|
|
# So prefer viewing the Event from the Server that is actually
|
|
# storing the video
|
|
return $this->Storage()->Server();
|
|
} else if ( $this->Monitor()->ServerId() ) {
|
|
# Assume that the server that recorded it has it
|
|
return $this->Monitor()->Server();
|
|
}
|
|
# A default Server will result in the use of ZM_DIR_EVENTS
|
|
return new Server();
|
|
}
|
|
|
|
public function getStreamSrc( $args=array(), $querySep='&' ) {
|
|
$Server = $this->Server();
|
|
|
|
# If we are in a multi-port setup, then use the multiport, else by
|
|
# passing null Server->Url will use the Port set in the Server setting
|
|
if ($args['mode'] == 'mp4') { #Downloading a video file. It is possible to reconsider the condition later.
|
|
#If the port is different from 80, the browser will start watching the video instead of downloading.
|
|
$port = null;
|
|
} else {
|
|
$port = ZM_MIN_STREAMING_PORT ?
|
|
ZM_MIN_STREAMING_PORT+$this->{'MonitorId'} :
|
|
null;
|
|
}
|
|
$streamSrc = $Server->Url($port);
|
|
|
|
if ( $this->{'DefaultVideo'} and $args['mode'] != 'jpeg' ) {
|
|
$streamSrc .= $Server->PathToIndex();
|
|
$args['eid'] = $this->{'Id'};
|
|
$args['view'] = (strtolower($args['mode']) == 'mp4hls') ? 'view_hls' : 'view_video';
|
|
} else {
|
|
$streamSrc .= $Server->PathToZMS();
|
|
|
|
$args['source'] = 'event';
|
|
$args['event'] = $this->{'Id'};
|
|
if ( ( (!isset($args['mode'])) or ( $args['mode'] != 'single' ) ) && !empty($GLOBALS['connkey']) ) {
|
|
$args['connkey'] = $GLOBALS['connkey'];
|
|
}
|
|
if ( ZM_RAND_STREAM ) {
|
|
$args['rand'] = time();
|
|
}
|
|
}
|
|
|
|
if ( ZM_OPT_USE_AUTH ) {
|
|
if ( ZM_AUTH_RELAY == 'hashed' ) {
|
|
$args['auth'] = generateAuthHash(ZM_AUTH_HASH_IPS);
|
|
// Include username so zms can filter by indexed Username column
|
|
// instead of iterating all users to validate the auth hash
|
|
if (!empty($_SESSION['username'])) $args['user'] = $_SESSION['username'];
|
|
} else if ( ZM_AUTH_RELAY == 'plain' ) {
|
|
$args['user'] = $_SESSION['username'];
|
|
$args['pass'] = $_SESSION['password'];
|
|
} else if ( ZM_AUTH_RELAY == 'none' ) {
|
|
$args['user'] = $_SESSION['username'];
|
|
}
|
|
}
|
|
|
|
$streamSrc .= '?'.http_build_query($args, '', $querySep);
|
|
|
|
return $streamSrc;
|
|
} // end function getStreamSrc
|
|
|
|
# The new='' is to so that if we pass null, we reset the value of DiskSpace.
|
|
# '' is not a valid DiskSpace so that tells us that nothing was passed whereas null (unknown) is.
|
|
function DiskSpace( $new='' ) {
|
|
if ( is_null($new) or ( $new != '' ) ) {
|
|
$this->{'DiskSpace'} = $new;
|
|
}
|
|
if ( (!property_exists($this, 'DiskSpace')) or (null === $this->{'DiskSpace'}) ) {
|
|
$this->{'DiskSpace'} = folder_size($this->Path());
|
|
if ($this->{'EndDateTime'} and $this->{'DiskSpace'}) {
|
|
# Finished events shouldn't grow in size much so we can commit it to the db.
|
|
#dbQuery('UPDATE Events SET DiskSpace=? WHERE Id=?', array($this->{'DiskSpace'}, $this->{'Id'}));
|
|
}
|
|
}
|
|
return $this->{'DiskSpace'};
|
|
}
|
|
|
|
function createListThumbnail( $overwrite=false ) {
|
|
# The idea here is that we don't really want to use the analysis jpeg as the thumbnail.
|
|
# The snapshot image will be generated during capturing
|
|
if ( file_exists($this->Path().'/snapshot.jpg') ) {
|
|
Debug("snapshot exists");
|
|
$frame = null;
|
|
} else {
|
|
# Load the frame with the highest score to use as a thumbnail
|
|
if ( !($frame = dbFetchOne( 'SELECT * FROM Frames WHERE EventId=? AND Score=? ORDER BY FrameId LIMIT 1', NULL, array( $this->{'Id'}, $this->{'MaxScore'} ) )) ) {
|
|
Error("Unable to find a Frame matching max score " . $this->{'MaxScore'} . ' for event ' . $this->{'Id'} );
|
|
// FIXME: What if somehow the db frame was lost or score was changed? Should probably try another search for any frame.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$imageData = $this->getImageSrc($frame, $scale, false, $overwrite);
|
|
if ( ! $imageData ) {
|
|
return false;
|
|
}
|
|
$thumbData = $frame;
|
|
$thumbData['Path'] = $imageData['thumbPath'];
|
|
$thumbData['Width'] = $this->ThumbnailWidth();
|
|
$thumbData['Height'] = $this->ThumbnailHeight();
|
|
$thumbData['url'] = '?view=image&eid='.$this->Id().'&fid='.$imageData['FrameId'].'&width='.$thumbData['Width'].'&height='.$thumbData['Height'];
|
|
|
|
return $thumbData;
|
|
} // end function createListThumbnail
|
|
|
|
function ThumbnailWidth( ) {
|
|
if ( ! ( property_exists($this, 'ThumbnailWidth') ) ) {
|
|
if ( ZM_WEB_LIST_THUMB_WIDTH ) {
|
|
$this->{'ThumbnailWidth'} = ZM_WEB_LIST_THUMB_WIDTH;
|
|
// Derive the height directly from the aspect ratio. Using an integer
|
|
// SCALE_BASE scale truncated to 0/1 for high-resolution monitors (e.g.
|
|
// 2688 wide -> scale 1), producing a squashed, wrong-aspect thumbnail
|
|
// (and a distorted hover-overlay). refs #3443
|
|
$this->{'ThumbnailHeight'} = (int)round($this->{'Height'} * ZM_WEB_LIST_THUMB_WIDTH / $this->{'Width'});
|
|
} elseif ( ZM_WEB_LIST_THUMB_HEIGHT ) {
|
|
$this->{'ThumbnailHeight'} = ZM_WEB_LIST_THUMB_HEIGHT;
|
|
$this->{'ThumbnailWidth'} = (int)round($this->{'Width'} * ZM_WEB_LIST_THUMB_HEIGHT / $this->{'Height'});
|
|
} else {
|
|
Fatal( "No thumbnail width or height specified, please check in Options->Web" );
|
|
}
|
|
}
|
|
return $this->{'ThumbnailWidth'};
|
|
} // end function ThumbnailWidth
|
|
|
|
function ThumbnailHeight( ) {
|
|
if ( ! ( property_exists($this, 'ThumbnailHeight') ) ) {
|
|
if ( ZM_WEB_LIST_THUMB_WIDTH ) {
|
|
$this->{'ThumbnailWidth'} = ZM_WEB_LIST_THUMB_WIDTH;
|
|
// Derive the height directly from the aspect ratio. Using an integer
|
|
// SCALE_BASE scale truncated to 0/1 for high-resolution monitors (e.g.
|
|
// 2688 wide -> scale 1), producing a squashed, wrong-aspect thumbnail
|
|
// (and a distorted hover-overlay). refs #3443
|
|
$this->{'ThumbnailHeight'} = (int)round($this->{'Height'} * ZM_WEB_LIST_THUMB_WIDTH / $this->{'Width'});
|
|
} elseif ( ZM_WEB_LIST_THUMB_HEIGHT ) {
|
|
$this->{'ThumbnailHeight'} = ZM_WEB_LIST_THUMB_HEIGHT;
|
|
$this->{'ThumbnailWidth'} = (int)round($this->{'Width'} * ZM_WEB_LIST_THUMB_HEIGHT / $this->{'Height'});
|
|
} else {
|
|
Fatal( "No thumbnail width or height specified, please check in Options->Web" );
|
|
}
|
|
}
|
|
return $this->{'ThumbnailHeight'};
|
|
} // end function ThumbnailHeight
|
|
|
|
function getThumbnailSrc( $args=array(), $querySep='&' ) {
|
|
# The thumbnail is theoretically the image with the most motion.
|
|
# We always store at least 1 image when capturing
|
|
|
|
$streamSrc = '';
|
|
$Server = $this->Server();
|
|
$streamSrc .= $Server->UrlToIndex(
|
|
ZM_MIN_STREAMING_PORT ?
|
|
ZM_MIN_STREAMING_PORT+$this->{'MonitorId'} :
|
|
null);
|
|
|
|
$args['eid'] = $this->{'Id'};
|
|
if (file_exists($this->Path().'/objdetect.jpg')) {
|
|
$args['fid'] = 'objdetect';
|
|
} else {
|
|
$args['fid'] = 'snapshot';
|
|
}
|
|
$args['view'] = 'image';
|
|
$args['width'] = $this->ThumbnailWidth();
|
|
$args['height'] = $this->ThumbnailHeight();
|
|
|
|
if ( ZM_OPT_USE_AUTH ) {
|
|
if ( ZM_AUTH_RELAY == 'hashed' ) {
|
|
$args['auth'] = generateAuthHash(ZM_AUTH_HASH_IPS);
|
|
// Include username so zms can filter by indexed Username column
|
|
// instead of iterating all users to validate the auth hash
|
|
if (!empty($_SESSION['username'])) $args['user'] = $_SESSION['username'];
|
|
} else if ( ZM_AUTH_RELAY == 'plain' ) {
|
|
$args['user'] = $_SESSION['username'];
|
|
$args['pass'] = $_SESSION['password'];
|
|
} else if ( ZM_AUTH_RELAY == 'none' ) {
|
|
$args['user'] = $_SESSION['username'];
|
|
}
|
|
}
|
|
|
|
return $streamSrc.'?'.http_build_query($args,'', $querySep);
|
|
} // end function getThumbnailSrc
|
|
|
|
// frame is an array representing the db row for a frame.
|
|
function getImageSrc($frame, $scale=SCALE_BASE, $captureOnly=false, $overwrite=false) {
|
|
$Storage = $this->Storage();
|
|
$Event = $this;
|
|
$eventPath = $Event->Path();
|
|
|
|
if ( $frame and !is_array($frame) ) {
|
|
Debug("Assuming that $frame is an Id");
|
|
$f = Frame::find_one(['Id'=>$frame]);
|
|
if ($f) {
|
|
$frame = (array)$f;
|
|
} else {
|
|
$frame = $this->find_virtual_frame($frame);
|
|
if (!$frame)
|
|
$frame = array('FrameId'=>$frame, 'Type'=>'', 'Delta'=>0);
|
|
}
|
|
}
|
|
|
|
if ( ( !$frame ) and file_exists($eventPath.'/snapshot.jpg') ) {
|
|
# No frame specified, so look for a snapshot to use
|
|
$captureImage = 'snapshot.jpg';
|
|
Debug('Frame not specified, using snapshot');
|
|
$frame = array('FrameId'=>'snapshot', 'Type'=>'', 'Delta'=>0);
|
|
} else {
|
|
$captureImage = sprintf('%0'.ZM_EVENT_IMAGE_DIGITS.'d-analyze.jpg', $frame['FrameId']);
|
|
if ( ! file_exists( $eventPath.'/'.$captureImage ) ) {
|
|
$captureImage = sprintf('%0'.ZM_EVENT_IMAGE_DIGITS.'d-capture.jpg', $frame['FrameId']);
|
|
if ( !file_exists($eventPath.'/'.$captureImage) ) {
|
|
# Generate the frame JPG
|
|
if ( $Event->DefaultVideo() ) {
|
|
$videoPath = $eventPath.'/'.$Event->DefaultVideo();
|
|
|
|
if ( !file_exists($videoPath) ) {
|
|
Error('Event claims to have a video file, but it does not seem to exist at '.$videoPath);
|
|
return '';
|
|
}
|
|
|
|
if ( !is_executable(ZM_PATH_FFMPEG) ) {
|
|
Error('ZM_PATH_FFMPEG is not a valid executable: '.ZM_PATH_FFMPEG);
|
|
return '';
|
|
}
|
|
$command = ZM_PATH_FFMPEG.' -ss '.escapeshellarg($frame['Delta']).' -i '.escapeshellarg($videoPath).' -frames:v 1 '.escapeshellarg($eventPath.'/'.$captureImage).' 2>&1';
|
|
Debug('Running '.$command);
|
|
$output = array();
|
|
$retval = 0;
|
|
exec($command, $output, $retval);
|
|
Debug("Retval: $retval, output: " . implode("\n", $output));
|
|
} else {
|
|
Error('Can\'t create frame images from video because there is no video file for event '.$Event->Id().' at ' .$Event->Path());
|
|
}
|
|
} // end if capture file exists
|
|
} // end if analyze file exists
|
|
} // end if frame or snapshot
|
|
|
|
$capturePath = $eventPath.'/'.$captureImage;
|
|
if ( !file_exists($capturePath) ) {
|
|
Error('Capture file does not exist at '.$capturePath);
|
|
}
|
|
|
|
$analysisImage = sprintf('%0'.ZM_EVENT_IMAGE_DIGITS.'d-analyse.jpg', $frame['FrameId']);
|
|
$analysisPath = $eventPath.'/'.$analysisImage;
|
|
|
|
$alarmFrame = $frame['Type'] == 'Alarm';
|
|
|
|
$hasAnalysisImage = $alarmFrame && file_exists($analysisPath) && filesize($analysisPath);
|
|
$isAnalysisImage = $hasAnalysisImage && !$captureOnly;
|
|
|
|
if ( !ZM_WEB_SCALE_THUMBS || !$scale || ($scale >= SCALE_BASE) || !function_exists('imagecreatefromjpeg') ) {
|
|
$imagePath = $thumbPath = $isAnalysisImage ? $analysisPath : $capturePath;
|
|
$imageFile = $imagePath;
|
|
$thumbFile = $thumbPath;
|
|
} else {
|
|
if ( version_compare(phpversion(), '4.3.10', '>=') )
|
|
$fraction = sprintf('%.3F', $scale/SCALE_BASE);
|
|
else
|
|
$fraction = sprintf('%.3f', $scale/SCALE_BASE);
|
|
$scale = (int)round($scale);
|
|
|
|
$thumbCapturePath = preg_replace('/\.jpg$/', "-$scale.jpg", $capturePath);
|
|
$thumbAnalysisPath = preg_replace('/\.jpg$/', "-$scale.jpg", $analysisPath);
|
|
|
|
if ( $isAnalysisImage ) {
|
|
$imagePath = $analysisPath;
|
|
$thumbPath = $thumbAnalysisPath;
|
|
} else {
|
|
$imagePath = $capturePath;
|
|
$thumbPath = $thumbCapturePath;
|
|
}
|
|
|
|
$thumbFile = $thumbPath;
|
|
if ( $overwrite || ! file_exists($thumbFile) || ! filesize($thumbFile) ) {
|
|
// Get new dimensions
|
|
list($imageWidth, $imageHeight) = getimagesize($imagePath);
|
|
$thumbWidth = $imageWidth * $fraction;
|
|
$thumbHeight = $imageHeight * $fraction;
|
|
|
|
// Resample
|
|
$thumbImage = imagecreatetruecolor($thumbWidth, $thumbHeight);
|
|
$image = imagecreatefromjpeg($imagePath);
|
|
imagecopyresampled($thumbImage, $image, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $imageWidth, $imageHeight);
|
|
|
|
if ( !imagejpeg($thumbImage, $thumbPath) )
|
|
Error("Can't create thumbnail '$thumbPath'");
|
|
}
|
|
} # Create thumbnails
|
|
|
|
$imageData = array(
|
|
'eventPath' => $eventPath,
|
|
'imagePath' => $imagePath,
|
|
'thumbPath' => $thumbPath,
|
|
'imageFile' => $imagePath,
|
|
'thumbFile' => $thumbFile,
|
|
'imageClass' => $alarmFrame?'alarm':'normal',
|
|
'isAnalysisImage' => $isAnalysisImage,
|
|
'hasAnalysisImage' => $hasAnalysisImage,
|
|
'FrameId' => $frame['FrameId'],
|
|
);
|
|
|
|
return $imageData;
|
|
} # getImageSrc
|
|
|
|
public function link_to($text=null) {
|
|
if ( !$text )
|
|
$text = $this->{'Id'};
|
|
return '<a href="?view=event&eid='. $this->{'Id'}.'">'.$text.'</a>';
|
|
}
|
|
|
|
public function file_exists() {
|
|
if ( file_exists( $this->Path().'/'.$this->DefaultVideo() ) ) {
|
|
return true;
|
|
}
|
|
if ( !defined('ZM_SERVER_ID') ) {
|
|
return false;
|
|
}
|
|
$Storage= $this->Storage();
|
|
$Server = $this->Server();
|
|
if ( $Server->Id() != ZM_SERVER_ID ) {
|
|
|
|
$url = $Server->UrlToApi() . '/events/'.$this->{'Id'}.'.json';
|
|
if ( ZM_OPT_USE_AUTH ) {
|
|
if ( ZM_AUTH_RELAY == 'hashed' ) {
|
|
$url .= '?auth='.generateAuthHash( ZM_AUTH_HASH_IPS );
|
|
} else if ( ZM_AUTH_RELAY == 'plain' ) {
|
|
$url .= '?user='.$_SESSION['username'];
|
|
$url .= '?pass='.$_SESSION['password'];
|
|
} else {
|
|
Error('Multi-Server requires AUTH_RELAY be either HASH or PLAIN');
|
|
return;
|
|
}
|
|
}
|
|
Debug("sending command to $url");
|
|
// use key 'http' even if you send the request to https://...
|
|
$options = array(
|
|
'http' => array(
|
|
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
|
|
'method' => 'GET',
|
|
'content' => ''
|
|
)
|
|
);
|
|
$context = stream_context_create($options);
|
|
try {
|
|
$result = file_get_contents($url, false, $context);
|
|
if ($result === FALSE) { /* Handle error */
|
|
Error("Error restarting zmc using $url");
|
|
}
|
|
$event_data = json_decode($result,true);
|
|
Debug(print_r($event_data['event']['Event'],1));
|
|
return $event_data['event']['Event']['fileExists'];
|
|
} catch ( Exception $e ) {
|
|
Error("Except $e thrown trying to get event data");
|
|
}
|
|
} # end if not local
|
|
return false;
|
|
} # end public function file_exists()
|
|
|
|
public function file_size() {
|
|
if ( file_exists($this->Path().'/'.$this->DefaultVideo()) ) {
|
|
return filesize($this->Path().'/'.$this->DefaultVideo());
|
|
}
|
|
if ( !defined('ZM_SERVER_ID') ) {
|
|
return false;
|
|
}
|
|
$Storage= $this->Storage();
|
|
$Server = $this->Server();
|
|
if ( $Server->Id() != ZM_SERVER_ID ) {
|
|
|
|
$url = $Server->UrlToApi().'/events/'.$this->{'Id'}.'.json';
|
|
if ( ZM_OPT_USE_AUTH ) {
|
|
if ( ZM_AUTH_RELAY == 'hashed' ) {
|
|
$url .= '?auth='.generateAuthHash(ZM_AUTH_HASH_IPS);
|
|
} elseif ( ZM_AUTH_RELAY == 'plain' ) {
|
|
$url .= '?user='.$_SESSION['username'];
|
|
$url .= '?pass='.$_SESSION['password'];
|
|
} else {
|
|
Error('Multi-Server requires AUTH_RELAY be either HASH or PLAIN');
|
|
return;
|
|
}
|
|
}
|
|
Debug("sending command to $url");
|
|
// use key 'http' even if you send the request to https://...
|
|
$options = array(
|
|
'http' => array(
|
|
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
|
|
'method' => 'GET',
|
|
'content' => ''
|
|
)
|
|
);
|
|
$context = stream_context_create($options);
|
|
try {
|
|
$result = file_get_contents($url, false, $context);
|
|
if ( $result === FALSE ) { /* Handle error */
|
|
Error("Error restarting zmc using $url");
|
|
}
|
|
$event_data = json_decode($result,true);
|
|
Debug(print_r($event_data['event']['Event'], 1));
|
|
return $event_data['event']['Event']['fileSize'];
|
|
} catch ( Exception $e ) {
|
|
Error("Except $e thrown trying to get event data");
|
|
}
|
|
} # end if not local
|
|
return 0;
|
|
} # end public function file_size()
|
|
|
|
public function can_delete() {
|
|
if ( $this->Archived() ) {
|
|
return false;
|
|
}
|
|
if ( !$this->EndDateTime() ) {
|
|
return false;
|
|
}
|
|
if ( !canEdit('Events') ) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function cant_delete_reason() {
|
|
if ( $this->Archived() ) {
|
|
return 'You cannot delete an archived event. Unarchive it first.';
|
|
} else if ( ! $this->EndDateTime() ) {
|
|
return 'You cannot delete an event while it is being recorded. Wait for it to finish.';
|
|
} else if ( ! canEdit('Events') ) {
|
|
return 'You do not have rights to edit Events.';
|
|
}
|
|
return 'Unknown reason';
|
|
}
|
|
|
|
function canView($u=null) {
|
|
global $user;
|
|
if (!$u) $u=$user;
|
|
if (!$u) {
|
|
# auth turned on and not logged in
|
|
return false;
|
|
}
|
|
if (!$this->Monitor()->canView($u)) return false;
|
|
|
|
if ($u->Events() != 'None') {
|
|
return true;
|
|
}
|
|
if ($u->Snapshots() != 'None') {
|
|
# If the event is contained in a snapshot, then we can still view it.
|
|
if (dbFetchOne('SELECT * FROM Snapshot_Events WHERE EventId=?', $this->Id()))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function canEdit($u=null) {
|
|
global $user;
|
|
if (!$u) $u=$user;
|
|
if (!$u) {
|
|
# auth turned on and not logged in
|
|
return false;
|
|
}
|
|
if (!$this->Monitor()->canView($u)) return false;
|
|
if ($u->Events() != 'Edit') {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function createVideo($format, $rate, $scale, $transform, $overwrite=false) {
|
|
$command = ZM_PATH_BIN.'/zmvideo.pl -e '.escapeshellarg($this->{'Id'})
|
|
.' -f '.escapeshellarg(preg_replace('/[^\w]/', '', $format))
|
|
.' -r '.escapeshellarg(sprintf('%.2F', ($rate/RATE_BASE)));
|
|
if (preg_match('/^\d+x\d+$/', $scale)) {
|
|
$command .= ' -S '.escapeshellarg($scale);
|
|
} else {
|
|
$command .= ' -s '.escapeshellarg(sprintf('%.2F', ($scale/SCALE_BASE)));
|
|
}
|
|
if ($transform != '') {
|
|
$transform = preg_replace('/[^\w=]/', '', $transform);
|
|
$command .= ' -t '.escapeshellarg($transform);
|
|
}
|
|
if ($overwrite)
|
|
$command .= ' -o';
|
|
$result = exec($command, $output, $status);
|
|
Debug("generating Video $command: result($result outptu:(".implode("\n", $output )." status($status");
|
|
return $status ? '' : rtrim($result);
|
|
}
|
|
|
|
public function find_virtual_frame($fid) {
|
|
$frame = null;
|
|
|
|
$previousBulkFrame = dbFetchOne(
|
|
'SELECT * FROM Frames WHERE EventId=? AND FrameId < ? ORDER BY FrameID DESC LIMIT 1',
|
|
NULL, array($this->Id(), $fid)
|
|
);
|
|
$nextBulkFrame = dbFetchOne(
|
|
'SELECT * FROM Frames WHERE EventId=? AND FrameId > ? ORDER BY FrameID ASC LIMIT 1',
|
|
NULL, array($this->Id(), $fid)
|
|
);
|
|
if ($previousBulkFrame and $nextBulkFrame) {
|
|
$frame = new Frame($previousBulkFrame);
|
|
$frame->FrameId($fid);
|
|
|
|
$percentage = ($frame->FrameId() - $previousBulkFrame['FrameId']) / ($nextBulkFrame['FrameId'] - $previousBulkFrame['FrameId']);
|
|
$frame->Delta($previousBulkFrame['Delta'] + floor( 100* ( $nextBulkFrame['Delta'] - $previousBulkFrame['Delta'] ) * $percentage )/100);
|
|
Debug('Got virtual frame from Bulk Frames previous delta: ' . $previousBulkFrame['Delta'] . ' + nextdelta:' . $nextBulkFrame['Delta'] . ' - ' . $previousBulkFrame['Delta'] . ' * ' . $percentage );
|
|
} else if ($previousBulkFrame) {
|
|
//If no next Frame we have to pull data from the Event itself
|
|
$frame = new Frame($previousBulkFrame);
|
|
$frame->FrameId($_REQUEST['fid']);
|
|
|
|
$percentage = ($frame->FrameId()/$this->Frames());
|
|
|
|
$frame->Delta(floor($this->Length() * $percentage));
|
|
}
|
|
return $frame;
|
|
}
|
|
|
|
public function Event_Tags() {
|
|
if (!isset($this->Event_Tags)) {
|
|
$this->Event_Tags = $this->Id() ? Event_Tag::find(['EventId'=>$this->Id()]) : [];
|
|
}
|
|
return $this->Event_Tags;
|
|
}
|
|
|
|
public function Tags() {
|
|
if (!isset($this->Tags)) {
|
|
$this->Tags = array_map(function($t){return $t->Tag();}, $this->Event_Tags());
|
|
} else {
|
|
Debug("Have Tags");
|
|
}
|
|
return $this->Tags;
|
|
}
|
|
|
|
public function GenerateVideo($rate=0, $fps=0, $scale=0, $size=0, $overwrite=false, $format='mp4', $transforms='') {
|
|
$event_path = $this->Path();
|
|
$video_name = preg_replace('/\s/', '_', $this->Name());
|
|
|
|
$file_parts = [$video_name];
|
|
if ( $rate ) {
|
|
$file_rate = str_replace('.', '_', $rate);
|
|
$file_rate = str_replace('_00', '', $file_rate);
|
|
$file_rate = preg_replace('/(_\d+)0+$/', '$1', $file_rate);
|
|
$file_parts[] = 'r'.$file_rate;
|
|
} else if ( $fps ) {
|
|
$file_fps = str_replace('.', '_', $fps);
|
|
$file_fps = str_replace('_00', '', $file_fps);
|
|
$file_fps = preg_replace('/(_\d+)0+$/', '$1', $file_rate);
|
|
$file_parts[] = 'R'.$file_fps;
|
|
}
|
|
|
|
if ( $scale ) {
|
|
$file_scale = str_replace('.', '_', $scale);
|
|
$file_scale = str_replace('/_00/', '', $file_scale);
|
|
$file_scale = preg_replace('/(_\d+)0+$/', '$1', $file_scale);
|
|
$file_parts[] = 's'.$file_scale;
|
|
} else if ( $size ) {
|
|
$file_size = 'S'.$size;
|
|
$file_parts[] = $file_size;
|
|
}
|
|
array_merge($file_parts, explode(',', $transforms));
|
|
$video_file = implode('-', $file_parts).'.'.$format;
|
|
if ( $overwrite || ! file_exists($video_file) ) {
|
|
Info("Creating video file $video_file for event ".$this->Id());
|
|
|
|
$frame_rate = sprintf('%.2f', $this->Frames()/$this->Length());
|
|
if ($rate) {
|
|
if ( $rate != 1.0 ) {
|
|
$frame_rate *= $rate;
|
|
}
|
|
} else if ( $fps ) {
|
|
$frame_rate = $fps;
|
|
}
|
|
|
|
$width = $this->Width();
|
|
$height = $this->Height();
|
|
$video_size = " {$width}x{$height}";
|
|
|
|
if ( $scale ) {
|
|
if ( $scale != 1.0 ) {
|
|
$width = int($width*$scale);
|
|
$height = int($height*$scale);
|
|
$video_size = " {$width}x{$height}";
|
|
}
|
|
} else if ( $size ) {
|
|
$video_size = $size;
|
|
}
|
|
$command = ZM_PATH_FFMPEG
|
|
." -y -r $frame_rate "
|
|
.ZM_FFMPEG_INPUT_OPTIONS
|
|
.' -i ' . $event_path.'/'.( $this->DefaultVideo() ? $this->DefaultVideo() : '%0'.ZM_EVENT_IMAGE_DIGITS .'d-capture.jpg' )
|
|
#. " -f concat -i /tmp/event_files.txt"
|
|
#
|
|
.implode(' ', array_map(function($t){ return ' -vf '.$t; }, explode(',', $transforms)))
|
|
." -s $video_size "
|
|
|
|
.ZM_FFMPEG_OUTPUT_OPTIONS
|
|
." '$event_path/$video_file' > $event_path/ffmpeg.log 2>&1"
|
|
;
|
|
Debug($command);
|
|
if(!exec(escapeshellcmd($command), $output, $rc)) {
|
|
Error("Unable to generate video, check $event_path/ffmpeg.log for details");
|
|
return;
|
|
}
|
|
|
|
Info("Finished $video_file");
|
|
return $video_file;
|
|
} else {
|
|
Info("Video file $video_file already exists for event {$this['Id']}");
|
|
return $video_file;
|
|
}
|
|
return;
|
|
} # end sub GenerateVideo
|
|
|
|
} # end class
|
|
?>
|