NOW() AT TIME ZONE 'UTC' SQL; $count = Application_Common_Database::prepareAndExecute($sql, [ ':file_id' => $p_fileId, ], 'column'); return is_numeric($count) && ($count != '0'); } public static function getAllFutureScheduledFiles($instanceId = null) { $sql = <<<'SQL' SELECT distinct(file_id) FROM cc_schedule WHERE ends > now() AT TIME ZONE 'UTC' AND file_id is not null SQL; $files = Application_Common_Database::prepareAndExecute($sql, []); $real_files = []; foreach ($files as $f) { $real_files[] = $f['file_id']; } return $real_files; } public static function getAllFutureScheduledWebstreams() { $sql = <<<'SQL' SELECT distinct(stream_id) FROM cc_schedule WHERE ends > now() AT TIME ZONE 'UTC' AND stream_id is not null SQL; $streams = Application_Common_Database::prepareAndExecute($sql, []); $real_streams = []; foreach ($streams as $s) { $real_streams[] = $s['stream_id']; } return $real_streams; } /** * Returns an array with 2 elements: artist and title name of the track that is currently playing. * Elements will be set to null if metadata is not set for those fields. * * Returns null if no track is currently playing. * * Data is based on GetPlayOrderRange() in this class. */ public static function getCurrentPlayingTrack() { $currentScheduleInfo = self::GetPlayOrderRange(); if (empty($currentScheduleInfo['tracks']['current'])) { return null; } $currentTrackArray = explode(' - ', $currentScheduleInfo['tracks']['current']['name']); return [ 'artist' => empty($currentTrackArray[0]) ? null : urlencode($currentTrackArray[0]), 'title' => empty($currentTrackArray[1]) ? null : urlencode($currentTrackArray[1]), ]; } /** * Returns data related to the scheduled items. * * @param null|mixed $utcTimeEnd * @param mixed $showsToRetrieve */ public static function GetPlayOrderRange($utcTimeEnd = null, $showsToRetrieve = 5) { // Everything in this function must be done in UTC. You will get a swift kick in the pants if you mess that up. // when timeEnd is unspecified, return to the default behaviour - set a range of 48 hours from current time if (!$utcTimeEnd) { $end = new DateTime(); $end->add(new DateInterval('P2D')); // Add 2 days $end->setTimezone(new DateTimeZone('UTC')); $utcTimeEnd = $end->format(DEFAULT_TIMESTAMP_FORMAT); } $utcNow = new DateTime('now', new DateTimeZone('UTC')); $shows = Application_Model_Show::getPrevCurrentNext($utcNow, $utcTimeEnd, $showsToRetrieve); $currentShowID = null; if (is_array($shows['currentShow']) && count($shows['currentShow']) > 0) { $currentShowID = $shows['currentShow']['instance_id']; } $source = self::_getSource(); $results = Application_Model_Schedule::getPreviousCurrentNextMedia($utcNow, $currentShowID, self::_getSource()); return [ 'station' => [ 'env' => APPLICATION_ENV, 'schedulerTime' => $utcNow->format(DEFAULT_TIMESTAMP_FORMAT), 'source_enabled' => $source, ], // Previous, current, next songs! 'tracks' => [ 'previous' => $results['previous'], 'current' => $results['current'], 'next' => $results['next'], ], // Current and next shows 'shows' => [ 'previous' => $shows['previousShow'], 'current' => $shows['currentShow'], 'next' => $shows['nextShow'], ], ]; } /** * Old version of the function for backwards compatibility. * * @deprecated */ public static function GetPlayOrderRangeOld() { // Everything in this function must be done in UTC. You will get a swift kick in the pants if you mess that up. $utcNow = new DateTime('now', new DateTimeZone('UTC')); $shows = Application_Model_Show::getPrevCurrentNextOld($utcNow); $currentShowID = count($shows['currentShow']) > 0 ? $shows['currentShow'][0]['instance_id'] : null; $source = self::_getSource(); $results = Application_Model_Schedule::getPreviousCurrentNextMedia($utcNow, $currentShowID, $source); return [ 'env' => APPLICATION_ENV, 'schedulerTime' => $utcNow->format(DEFAULT_TIMESTAMP_FORMAT), // Previous, current, next songs! 'previous' => $results['previous'] != null ? $results['previous'] : (count($shows['previousShow']) > 0 ? $shows['previousShow'][0] : null), 'current' => $results['current'] != null ? $results['current'] : ((count($shows['currentShow']) > 0 && $shows['currentShow'][0]['record'] == 1) ? $shows['currentShow'][0] : null), 'next' => $results['next'] != null ? $results['next'] : (count($shows['nextShow']) > 0 ? $shows['nextShow'][0] : null), // Current and next shows 'currentShow' => $shows['currentShow'], 'nextShow' => $shows['nextShow'], 'source_enabled' => $source, ]; } /** * Attempts to find a media item (track or webstream) that is currently playing. * If a current media item is currently playing, this function then attempts to * find an item that played previously and is scheduled to play next. * * @param $utcNow DateTime current time in UTC * @param $currentShowInstanceId int id of the show instance currently playing * @param $source string the current prioritized source * * @return array with data about the previous, current, and next media items playing. * Returns an empty arrays if there is no media item currently playing */ public static function getPreviousCurrentNextMedia($utcNow, $currentShowInstanceId, $source) { $timeZone = new DateTimeZone('UTC'); // This function works entirely in UTC. assert(get_class($utcNow) === 'DateTime'); assert($utcNow->getTimeZone() == $timeZone); $results['previous'] = null; $results['current'] = null; $results['next'] = null; $sql = 'select s.id, s.starts, s.ends, s.file_id, s.stream_id, s.media_item_played, s.instance_id, si.ends as show_ends from cc_schedule s left join cc_show_instances si on s.instance_id = si.id where s.playout_status > 0 and s.starts <= :p1 and s.ends >= :p2 and s.instance_id = :p3 order by starts desc limit 1'; $params = [ ':p1' => $utcNow->format(DEFAULT_TIMESTAMP_FORMAT), ':p2' => $utcNow->format(DEFAULT_TIMESTAMP_FORMAT), ':p3' => $currentShowInstanceId, ]; $rows = Application_Common_Database::prepareAndExecute($sql, $params); // If live streaming (master or show source) is enabled, set the current // track information to the current show values if ($source != self::SCHEDULED_SOURCE_NAME) { $show = Application_Model_Show::getCurrentShow(); $results['current'] = isset($show[0]) ? [ 'starts' => $show[0]['starts'], 'ends' => $show[0]['ends'], 'type' => _('livestream'), 'name' => $show[0]['name'] . ' - ' . _(self::LIVE_STREAM), 'media_item_played' => false, 'record' => '0', ] : null; } elseif (count($rows) >= 1) { $currentMedia = $rows[0]; if ($currentMedia['ends'] > $currentMedia['show_ends']) { $currentMedia['ends'] = $currentMedia['show_ends']; } $currentMetadata = null; $currentMediaName = ''; $currentMediaFileId = $currentMedia['file_id']; $currentMediaStreamId = $currentMedia['stream_id']; if (isset($currentMediaFileId)) { $currentMediaType = 'track'; $currentFile = CcFilesQuery::create() ->filterByDbId($currentMediaFileId) ->findOne(); $currentMediaName = $currentFile->getDbArtistName() . ' - ' . $currentFile->getDbTrackTitle(); $currentMetadata = CcFiles::sanitizeResponse($currentFile); } elseif (isset($currentMediaStreamId)) { $currentMediaType = 'webstream'; $currentWebstream = CcWebstreamQuery::create() ->filterByDbId($currentMediaStreamId) ->findOne(); $currentWebstreamMetadata = CcWebstreamMetadataQuery::create() ->filterByDbInstanceId($currentMedia['id']) ->orderByDbStartTime(Criteria::DESC) ->findOne(); $currentMediaName = $currentWebstream->getDbName(); if (isset($currentWebstreamMetadata)) { $currentMediaName .= ' - ' . $currentWebstreamMetadata->getDbLiquidsoapData(); } } else { $currentMediaType = null; } $results['current'] = [ 'starts' => $currentMedia['starts'], 'ends' => $currentMedia['ends'], 'type' => $currentMediaType, 'name' => $currentMediaName, 'media_item_played' => $currentMedia['media_item_played'], 'metadata' => $currentMetadata, 'record' => '0', ]; } $previousMedia = CcScheduleQuery::create() ->filterByDbEnds($utcNow, Criteria::LESS_THAN) ->filterByDbPlayoutStatus(0, Criteria::GREATER_THAN) ->orderByDbStarts(Criteria::DESC) ->findOne(); if (isset($previousMedia)) { $previousMetadata = null; $previousMediaName = ''; $previousMediaFileId = $previousMedia->getDbFileId(); $previousMediaStreamId = $previousMedia->getDbStreamId(); if (isset($previousMediaFileId)) { $previousMediaType = 'track'; $previousFile = CcFilesQuery::create() ->filterByDbId($previousMediaFileId) ->findOne(); if (isset($previousFile)) { $previousMediaName = $previousFile->getDbArtistName() . ' - ' . $previousFile->getDbTrackTitle(); $previousMetadata = CcFiles::sanitizeResponse($previousFile); } } elseif (isset($previousMediaStreamId)) { $previousMediaName = null; $previousMediaType = 'webstream'; $previousWebstream = CcWebstreamQuery::create() ->filterByDbId($previousMediaStreamId) ->findOne(); $previousMediaName = $previousWebstream->getDbName(); } else { $previousMediaType = null; } $results['previous'] = [ 'starts' => $previousMedia->getDbStarts(), 'ends' => $previousMedia->getDbEnds(), 'type' => $previousMediaType, 'name' => $previousMediaName, 'metadata' => $previousMetadata, ]; } $nextMedia = CcScheduleQuery::create() ->filterByDbStarts($utcNow, Criteria::GREATER_THAN) ->filterByDbPlayoutStatus(0, Criteria::GREATER_THAN) ->orderByDbStarts(Criteria::ASC) ->findOne(); if (isset($nextMedia)) { $nextMetadata = null; $nextMediaName = ''; $nextMediaFileId = $nextMedia->getDbFileId(); $nextMediaStreamId = $nextMedia->getDbStreamId(); if (isset($nextMediaFileId)) { $nextMediaType = 'track'; $nextFile = CcFilesQuery::create() ->filterByDbId($nextMediaFileId) ->findOne(); $nextMetadata = CcFiles::sanitizeResponse($nextFile); $nextMediaName = $nextFile->getDbArtistName() . ' - ' . $nextFile->getDbTrackTitle(); } elseif (isset($nextMediaStreamId)) { $nextMediaType = 'webstream'; $nextWebstream = CcWebstreamQuery::create() ->filterByDbId($nextMediaStreamId) ->findOne(); $nextMediaName = $nextWebstream->getDbName(); } else { $nextMediaType = null; } $results['next'] = [ 'starts' => $nextMedia->getDbStarts(), 'ends' => $nextMedia->getDbEnds(), 'type' => $nextMediaType, 'name' => $nextMediaName, 'metadata' => $nextMetadata, ]; } return $results; } /** * Get the current prioritized source. * * Priority order is Master->Live/Show->Scheduled. * * @return string the source name */ private static function _getSource() { $live_dj = Application_Model_Preference::GetSourceStatus('live_dj'); $master_dj = Application_Model_Preference::GetSourceStatus('master_dj'); return $master_dj ? self::MASTER_SOURCE_NAME : ($live_dj ? self::SHOW_SOURCE_NAME : self::SCHEDULED_SOURCE_NAME); } public static function GetLastScheduleItem($p_timeNow) { $sql = <<<'SQL' SELECT ft.artist_name, ft.track_title, st.starts AS starts, st.ends AS ends FROM cc_schedule st LEFT JOIN cc_files ft ON st.file_id = ft.id LEFT JOIN cc_show_instances sit ON st.instance_id = sit.id -- this and the next line are necessary since we can overbook shows. WHERE st.ends < TIMESTAMP :timeNow AND st.starts >= sit.starts AND st.starts < sit.ends ORDER BY st.ends DESC LIMIT 1; SQL; return Application_Common_Database::prepareAndExecute($sql, [':timeNow' => $p_timeNow]); } public static function GetCurrentScheduleItem($p_timeNow, $p_instanceId) { /* Note that usually there will be one result returned. In some * rare cases two songs are returned. This happens when a track * that was overbooked from a previous show appears as if it * hasnt ended yet (track end time hasn't been reached yet). For * this reason, we need to get the track that starts later, as * this is the *real* track that is currently playing. So this * is why we are ordering by track start time. */ $sql = 'SELECT *' . ' FROM cc_schedule st' . ' LEFT JOIN cc_files ft' . ' ON st.file_id = ft.id' . ' WHERE st.starts <= TIMESTAMP :timeNow1' . ' AND st.instance_id = :instanceId' . ' AND st.ends > TIMESTAMP :timeNow2' . ' ORDER BY st.starts DESC' . ' LIMIT 1'; return Application_Common_Database::prepareAndExecute($sql, [':timeNow1' => $p_timeNow, ':instanceId' => $p_instanceId, ':timeNow2' => $p_timeNow]); } public static function GetNextScheduleItem($p_timeNow) { $sql = 'SELECT' . ' ft.artist_name, ft.track_title,' . ' st.starts as starts, st.ends as ends' . ' FROM cc_schedule st' . ' LEFT JOIN cc_files ft' . ' ON st.file_id = ft.id' . ' LEFT JOIN cc_show_instances sit' . ' ON st.instance_id = sit.id' . ' WHERE st.starts > TIMESTAMP :timeNow' . ' AND st.starts >= sit.starts' // this and the next line are necessary since we can overbook shows. . ' AND st.starts < sit.ends' . ' ORDER BY st.starts' . ' LIMIT 1'; return Application_Common_Database::prepareAndExecute($sql, [':timeNow' => $p_timeNow]); } /* * * @param DateTime $p_startDateTime * * @param DateTime $p_endDateTime * * @return array $scheduledItems * */ public static function GetScheduleDetailItems($p_start, $p_end, $p_shows, $p_show_instances) { $p_start_str = $p_start->format(DEFAULT_TIMESTAMP_FORMAT); $p_end_str = $p_end->format(DEFAULT_TIMESTAMP_FORMAT); // We need to search 48 hours before and after the show times so that that we // capture all of the show's contents. $p_track_start = $p_start->sub(new DateInterval('PT48H'))->format(DEFAULT_TIMESTAMP_FORMAT); $p_track_end = $p_end->add(new DateInterval('PT48H'))->format(DEFAULT_TIMESTAMP_FORMAT); $templateSql = <<<'SQL' SELECT DISTINCT sched.starts AS sched_starts, sched.ends AS sched_ends, sched.id AS sched_id, sched.cue_in AS cue_in, sched.cue_out AS cue_out, sched.fade_in AS fade_in, sched.fade_out AS fade_out, sched.playout_status AS playout_status, sched.instance_id AS sched_instance_id, %%columns%% FROM (%%join%%) SQL; $filesColumns = <<<'SQL' ft.track_title AS file_track_title, ft.artist_name AS file_artist_name, ft.album_title AS file_album_title, ft.length AS file_length, ft.file_exists AS file_exists, ft.mime AS file_mime SQL; $filesJoin = <<<'SQL' cc_schedule AS sched JOIN cc_files AS ft ON (sched.file_id = ft.id AND ((sched.starts >= :fj_ts_1 AND sched.starts < :fj_ts_2) OR (sched.ends > :fj_ts_3 AND sched.ends <= :fj_ts_4) OR (sched.starts <= :fj_ts_5 AND sched.ends >= :fj_ts_6)) ) SQL; $paramMap = [ ':fj_ts_1' => $p_track_start, ':fj_ts_2' => $p_track_end, ':fj_ts_3' => $p_track_start, ':fj_ts_4' => $p_track_end, ':fj_ts_5' => $p_track_start, ':fj_ts_6' => $p_track_end, ]; $filesSql = str_replace( '%%columns%%', $filesColumns, $templateSql ); $filesSql = str_replace( '%%join%%', $filesJoin, $filesSql ); $streamColumns = <<<'SQL' ws.name AS file_track_title, sub.login AS file_artist_name, ws.description AS file_album_title, ws.length AS file_length, 't'::BOOL AS file_exists, ws.mime AS file_mime SQL; $streamJoin = <<<'SQL' cc_schedule AS sched JOIN cc_webstream AS ws ON (sched.stream_id = ws.id AND ((sched.starts >= :sj_ts_1 AND sched.starts < :sj_ts_2) OR (sched.ends > :sj_ts_3 AND sched.ends <= :sj_ts_4) OR (sched.starts <= :sj_ts_5 AND sched.ends >= :sj_ts_6)) ) LEFT JOIN cc_subjs AS sub ON (ws.creator_id = sub.id) SQL; $map = [ ':sj_ts_1' => $p_track_start, ':sj_ts_2' => $p_track_end, ':sj_ts_3' => $p_track_start, ':sj_ts_4' => $p_track_end, ':sj_ts_5' => $p_track_start, ':sj_ts_6' => $p_track_end, ]; $paramMap += $map; $streamSql = str_replace( '%%columns%%', $streamColumns, $templateSql ); $streamSql = str_replace( '%%join%%', $streamJoin, $streamSql ); $showPredicate = ''; if (count($p_shows) > 0) { $params = []; $map = []; for ($i = 0, $len = count($p_shows); $i < $len; ++$i) { $holder = ':show_' . $i; $params[] = $holder; $map[$holder] = $p_shows[$i]; } $showPredicate = ' AND show_id IN (' . implode(',', $params) . ')'; $paramMap += $map; } elseif (count($p_show_instances) > 0) { $showPredicate = ' AND si.id IN (' . implode(',', $p_show_instances) . ')'; } $sql = <<= :ts_1 AND si.starts < :ts_2) OR (si.ends > :ts_3 AND si.ends <= :ts_4) OR (si.starts <= :ts_5 AND si.ends >= :ts_6)) ORDER BY si_starts, sched_starts; SQL; $map = [ ':ts_1' => $p_start_str, ':ts_2' => $p_end_str, ':ts_3' => $p_start_str, ':ts_4' => $p_end_str, ':ts_5' => $p_start_str, ':ts_6' => $p_end_str, ]; $paramMap += $map; return Application_Common_Database::prepareAndExecute( $sql, $paramMap, Application_Common_Database::ALL ); } public static function UpdateMediaPlayedStatus($p_id) { $sql = 'UPDATE cc_schedule' . ' SET media_item_played=TRUE'; // we need to update 'broadcasted' column as well // check the current switch status $live_dj = Application_Model_Preference::GetSourceSwitchStatus('live_dj') == 'on'; $master_dj = Application_Model_Preference::GetSourceSwitchStatus('master_dj') == 'on'; $scheduled_play = Application_Model_Preference::GetSourceSwitchStatus('scheduled_play') == 'on'; if (!$live_dj && !$master_dj && $scheduled_play) { $sql .= ', broadcasted=1'; } $sql .= ' WHERE id=:pid'; $map = [':pid' => $p_id]; Application_Common_Database::prepareAndExecute( $sql, $map, Application_Common_Database::EXECUTE ); } public static function UpdateBrodcastedStatus($dateTime, $value) { $now = $dateTime->format(DEFAULT_TIMESTAMP_FORMAT); $sql = <<<'SQL' UPDATE cc_schedule SET broadcasted=:broadcastedValue WHERE starts <= :starts::TIMESTAMP AND ends >= :ends::TIMESTAMP SQL; return Application_Common_Database::prepareAndExecute($sql, [ ':broadcastedValue' => $value, ':starts' => $now, ':ends' => $now, ], 'execute'); } public static function getSchduledPlaylistCount() { $sql = 'SELECT count(*) as cnt FROM cc_schedule'; return Application_Common_Database::prepareAndExecute( $sql, [], Application_Common_Database::COLUMN ); } /** * Convert a time string in the format "YYYY-MM-DD HH:mm:SS" * to "YYYY-MM-DD-HH-mm-SS". * * @param string $p_time * * @return string */ private static function AirtimeTimeToPypoTime($p_time) { $p_time = substr($p_time, 0, 19); $p_time = str_replace(' ', '-', $p_time); return str_replace(':', '-', $p_time); } /** * Convert a time string in the format "YYYY-MM-DD-HH-mm-SS" to * "YYYY-MM-DD HH:mm:SS". * * @param string $p_time * * @return string */ private static function PypoTimeToAirtimeTime($p_time) { $t = explode('-', $p_time); return $t[0] . '-' . $t[1] . '-' . $t[2] . ' ' . $t[3] . ':' . $t[4] . ':00'; } /** * Return true if the input string is in the format YYYY-MM-DD-HH-mm. * * @param string $p_time * * @return bool */ public static function ValidPypoTimeFormat($p_time) { $t = explode('-', $p_time); if (count($t) != 5) { return false; } foreach ($t as $part) { if (!is_numeric($part)) { return false; } } return true; } /** * Converts a time value as a string (with format HH:MM:SS.mmmmmm) to * millisecs. * * @param string $p_time * * @return int */ public static function WallTimeToMillisecs($p_time) { $t = explode(':', $p_time); $millisecs = 0; if (strpos($t[2], '.')) { $secParts = explode('.', $t[2]); $millisecs = $secParts[1]; $millisecs = str_pad(substr($millisecs, 0, 3), 3, '0'); $millisecs = intval($millisecs); $seconds = intval($secParts[0]); } else { $seconds = intval($t[2]); } return $millisecs + ($seconds * 1000) + ($t[1] * 60 * 1000) + ($t[0] * 60 * 60 * 1000); } /** * Returns an array of schedule items from cc_schedule table. Tries * to return at least 3 items (if they are available). The parameters * $p_startTime and $p_endTime specify the range. Schedule items returned * do not have to be entirely within this range. It is enough that the end * or beginning of the scheduled item is in the range. * * @param string $p_startTime * In the format YYYY-MM-DD HH:MM:SS.nnnnnn * @param string $p_endTime * In the format YYYY-MM-DD HH:MM:SS.nnnnnn * * @return array * Returns null if nothing found, else an array of associative * arrays representing each row */ public static function getItems($p_startTime, $p_endTime) { $baseQuery = <<<'SQL' SELECT st.file_id AS file_id, st.id AS id, st.instance_id AS instance_id, st.starts AS start, st.ends AS end, st.cue_in AS cue_in, st.cue_out AS cue_out, st.fade_in AS fade_in, st.fade_out AS fade_out, si.starts AS show_start, si.ends AS show_end, s.name AS show_name, f.id AS file_id, f.replay_gain AS replay_gain, ws.id AS stream_id, ws.url AS url FROM cc_schedule AS st LEFT JOIN cc_show_instances AS si ON st.instance_id = si.id LEFT JOIN cc_show AS s ON s.id = si.show_id LEFT JOIN cc_files AS f ON st.file_id = f.id LEFT JOIN cc_webstream AS ws ON st.stream_id = ws.id SQL; $predicates = <<<'SQL' WHERE st.ends > :startTime1 AND st.starts < :endTime AND st.playout_status > 0 AND si.ends > :startTime2 AND si.modified_instance = 'f' ORDER BY st.starts SQL; $sql = $baseQuery . ' ' . $predicates; $rows = Application_Common_Database::prepareAndExecute($sql, [ ':startTime1' => $p_startTime, ':endTime' => $p_endTime, ':startTime2' => $p_startTime, ]); if (count($rows) < 3) { $dt = new DateTime('@' . time()); $dt->add(new DateInterval('PT24H')); $range_end = $dt->format(DEFAULT_TIMESTAMP_FORMAT); $predicates = <<<'SQL' WHERE st.ends > :startTime1 AND st.starts < :rangeEnd AND st.playout_status > 0 AND si.ends > :startTime2 AND si.modified_instance = 'f' ORDER BY st.starts LIMIT 3 SQL; $sql = $baseQuery . ' ' . $predicates . ' '; $rows = Application_Common_Database::prepareAndExecute( $sql, [ ':startTime1' => $p_startTime, ':rangeEnd' => $range_end, ':startTime2' => $p_startTime, ] ); } return $rows; } /** * This function will ensure that an existing index in the * associative array is never overwritten, instead appending * _0, _1, _2, ... to the end of the key to make sure it is unique. * * @param mixed $data * @param mixed $time * @param mixed $item */ private static function appendScheduleItem(&$data, $time, $item) { $key = $time; $i = 0; while (array_key_exists($key, $data['media'])) { $key = "{$time}_{$i}"; ++$i; } $data['media'][$key] = $item; } private static function createInputHarborKickTimes(&$data, $range_start, $range_end) { $utcTimeZone = new DateTimeZone('UTC'); $kick_times = Application_Model_ShowInstance::GetEndTimeOfNextShowWithLiveDJ($range_start, $range_end); foreach ($kick_times as $kick_time_info) { $kick_time = $kick_time_info['ends']; $temp = explode('.', Application_Model_Preference::GetDefaultTransitionFade()); // we round down transition time since PHP cannot handle millisecond. We need to // handle this better in the future $transition_time = intval($temp[0]); $switchOffDataTime = new DateTime($kick_time, $utcTimeZone); $switch_off_time = $switchOffDataTime->sub(new DateInterval('PT' . $transition_time . 'S')); $switch_off_time = $switch_off_time->format(DEFAULT_TIMESTAMP_FORMAT); $kick_start = self::AirtimeTimeToPypoTime($kick_time); $data['media'][$kick_start]['start'] = $kick_start; $data['media'][$kick_start]['end'] = $kick_start; $data['media'][$kick_start]['event_type'] = 'kick_out'; $data['media'][$kick_start]['type'] = 'event'; if ($kick_time !== $switch_off_time) { $switch_start = self::AirtimeTimeToPypoTime($switch_off_time); $data['media'][$switch_start]['start'] = $switch_start; $data['media'][$switch_start]['end'] = $switch_start; $data['media'][$switch_start]['event_type'] = 'switch_off'; $data['media'][$switch_start]['type'] = 'event'; } } } /** * Appends schedule "events" to an array of schedule events that gets * sent to PYPO. Each schedule event contains information PYPO and * Liquidsoap need for playout. * * @param array $data array to be filled with schedule info - $item(s) * @param array $item schedule info about one track * @param int $media_id scheduled item's cc_files id * @param string $uri path to the scheduled item's physical location * @param mixed $filesize */ private static function createFileScheduleEvent(&$data, $item, $media_id, $uri, $filesize) { $start = self::AirtimeTimeToPypoTime($item['start']); $end = self::AirtimeTimeToPypoTime($item['end']); $replay_gain = is_null($item['replay_gain']) ? '0' : $item['replay_gain']; $replay_gain += Application_Model_Preference::getReplayGainModifier(); if (!Application_Model_Preference::GetEnableReplayGain()) { $replay_gain = 0; } $fileMetadata = CcFiles::sanitizeResponse(CcFilesQuery::create()->findPk($media_id)); $schedule_item = [ 'id' => $media_id, 'type' => 'file', 'track_title' => $fileMetadata['track_title'], 'artist_name' => $fileMetadata['artist_name'], 'mime' => $fileMetadata['mime'], 'row_id' => $item['id'], 'uri' => $uri, 'fade_in' => Application_Model_Schedule::WallTimeToMillisecs($item['fade_in']), 'fade_out' => Application_Model_Schedule::WallTimeToMillisecs($item['fade_out']), 'cue_in' => Application_Common_DateHelper::CalculateLengthInSeconds($item['cue_in']), 'cue_out' => Application_Common_DateHelper::CalculateLengthInSeconds($item['cue_out']), 'start' => $start, 'end' => $end, 'show_name' => $item['show_name'], 'replay_gain' => $replay_gain, 'filesize' => $filesize, ]; if ($schedule_item['cue_in'] > $schedule_item['cue_out']) { $schedule_item['cue_in'] = $schedule_item['cue_out']; } self::appendScheduleItem($data, $start, $schedule_item); } private static function createStreamScheduleEvent(&$data, $item, $media_id, $uri) { $start = self::AirtimeTimeToPypoTime($item['start']); $end = self::AirtimeTimeToPypoTime($item['end']); // create an event to start stream buffering 5 seconds ahead of the streams actual time. $buffer_start = new DateTime($item['start'], new DateTimeZone('UTC')); $buffer_start->sub(new DateInterval('PT5S')); $stream_buffer_start = self::AirtimeTimeToPypoTime($buffer_start->format(DEFAULT_TIMESTAMP_FORMAT)); $schedule_common = [ 'row_id' => $item['id'], 'id' => $media_id, 'uri' => $uri, 'show_name' => $item['show_name'], ]; $schedule_item = array_merge($schedule_common, [ 'start' => $stream_buffer_start, 'end' => $stream_buffer_start, 'type' => 'stream_buffer_start', ]); self::appendScheduleItem($data, $start, $schedule_item); $schedule_item = array_merge($schedule_common, [ 'start' => $start, 'end' => $end, 'type' => 'stream_output_start', ]); self::appendScheduleItem($data, $start, $schedule_item); // since a stream never ends we have to insert an additional "kick stream" event. The "start" // time of this event is the "end" time of the stream minus 1 second. $dt = new DateTime($item['end'], new DateTimeZone('UTC')); $dt->sub(new DateInterval('PT1S')); $stream_end = self::AirtimeTimeToPypoTime($dt->format(DEFAULT_TIMESTAMP_FORMAT)); $schedule_item = array_merge($schedule_common, [ 'start' => $stream_end, 'end' => $stream_end, 'type' => 'stream_buffer_end', ]); self::appendScheduleItem($data, $stream_end, $schedule_item); $schedule_item = array_merge($schedule_common, [ 'start' => $stream_end, 'end' => $stream_end, 'type' => 'stream_output_end', ]); self::appendScheduleItem($data, $stream_end, $schedule_item); } private static function getRangeStartAndEnd($p_fromDateTime, $p_toDateTime) { $CC_CONFIG = Config::getConfig(); $utcTimeZone = new DateTimeZone('UTC'); /* if $p_fromDateTime and $p_toDateTime function parameters are null, then set range * from "now" to "now + 24 hours". */ if (is_null($p_fromDateTime)) { $t1 = new DateTime('@' . time(), $utcTimeZone); $range_start = $t1->format(DEFAULT_TIMESTAMP_FORMAT); } else { $range_start = Application_Model_Schedule::PypoTimeToAirtimeTime($p_fromDateTime); } if (is_null($p_fromDateTime)) { $t2 = new DateTime('@' . time(), $utcTimeZone); $cache_ahead_hours = $CC_CONFIG['cache_ahead_hours']; if (is_numeric($cache_ahead_hours)) { // make sure we are not dealing with a float $cache_ahead_hours = intval($cache_ahead_hours); } else { $cache_ahead_hours = 1; } $t2->add(new DateInterval('PT' . $cache_ahead_hours . 'H')); $range_end = $t2->format(DEFAULT_TIMESTAMP_FORMAT); } else { $range_end = Application_Model_Schedule::PypoTimeToAirtimeTime($p_toDateTime); } return [$range_start, $range_end]; } private static function createScheduledEvents(&$data, $range_start, $range_end) { $utcTimeZone = new DateTimeZone('UTC'); $items = self::getItems($range_start, $range_end); foreach ($items as $item) { $showEndDateTime = new DateTime($item['show_end'], $utcTimeZone); $trackStartDateTime = new DateTime($item['start'], $utcTimeZone); $trackEndDateTime = new DateTime($item['end'], $utcTimeZone); if ($trackStartDateTime->getTimestamp() > $showEndDateTime->getTimestamp()) { // do not send any tracks that start past their show's end time continue; } if ($trackEndDateTime->getTimestamp() > $showEndDateTime->getTimestamp()) { $di = $trackStartDateTime->diff($showEndDateTime); $item['cue_out'] = $di->format('%H:%i:%s') . '.000'; $item['end'] = $showEndDateTime->format(DEFAULT_TIMESTAMP_FORMAT); } if (!is_null($item['file_id'])) { // row is from "file" $media_id = $item['file_id']; $storedFile = Application_Model_StoredFile::RecallById($media_id); $file = $storedFile->getPropelOrm(); // Even local files are downloaded through the REST API in case we need to transform // their filenames (eg. in the case of a bad file extension, because Liquidsoap won't play them) $uri = Config::getPublicUrl() . 'rest/media/' . $media_id; // $uri = $file->getAbsoluteFilePath(); $filesize = $file->getFileSize(); self::createFileScheduleEvent($data, $item, $media_id, $uri, $filesize); } elseif (!is_null($item['stream_id'])) { // row is type "webstream" $media_id = $item['stream_id']; $uri = $item['url']; self::createStreamScheduleEvent($data, $item, $media_id, $uri); } // throw new Exception("Unknown schedule type: ".print_r($item, true)); // It's currently possible (and normal) to get cc_schedule rows without // a file_id or stream_id. If you're using linked shows, placeholder rows can be put // in the schedule when you cancel a track or delete stuff, so we should not throw an exception // here and instead just ignore it. } } // Check if two events are less than or equal to 1 second apart public static function areEventsLinked($event1, $event2) { $dt1 = DateTime::createFromFormat('Y-m-d-H-i-s', $event1['start']); $dt2 = DateTime::createFromFormat('Y-m-d-H-i-s', $event2['start']); $seconds = $dt2->getTimestamp() - $dt1->getTimestamp(); return $seconds <= 1; } /** * Streams are a 4 stage process. * 1) start buffering stream 5 seconds ahead of its start time * 2) at the start time tell liquidsoap to switch to this source * 3) at the end time, tell liquidsoap to stop reading this stream * 4) at the end time, tell liquidsoap to switch away from input.http source. * * When we have two streams back-to-back, some of these steps are unnecessary * for the second stream. Instead of sending commands 1,2,3,4,1,2,3,4 we should * send 1,2,1,2,3,4 - We don't need to tell liquidsoap to stop reading (#3), because #1 * of the next stream implies this when we pass in a new url. We also don't need #4. * * There's a special case here is well. When the back-to-back streams are the same, we * can collapse the instructions 1,2,(3,4,1,2),3,4 to 1,2,3,4. We basically cut out the * middle part. This function handles this. * * @param mixed $data */ private static function foldData(&$data) { $previous_key = null; $previous_val = null; $previous_previous_key = null; $previous_previous_val = null; $previous_previous_previous_key = null; $previous_previous_previous_val = null; foreach ($data as $k => $v) { if ( $v['type'] == 'stream_output_start' && !is_null($previous_previous_val) && $previous_previous_val['type'] == 'stream_output_end' && self::areEventsLinked($previous_previous_val, $v) ) { unset($data[$previous_previous_previous_key], $data[$previous_previous_key], $data[$previous_key]); if ($previous_previous_val['uri'] == $v['uri']) { unset($data[$k]); } } $previous_previous_previous_key = $previous_previous_key; $previous_previous_previous_val = $previous_previous_val; $previous_previous_key = $previous_key; $previous_previous_val = $previous_val; $previous_key = $k; $previous_val = $v; } } public static function getSchedule($p_fromDateTime = null, $p_toDateTime = null) { // generate repeating shows if we are fetching the schedule // for days beyond the shows_populated_until value in cc_pref $needScheduleUntil = $p_toDateTime; if (is_null($needScheduleUntil)) { $needScheduleUntil = new DateTime('now', new DateTimeZone('UTC')); $needScheduleUntil->add(new DateInterval('P1D')); } Application_Model_Show::createAndFillShowInstancesPastPopulatedUntilDate($needScheduleUntil); [$range_start, $range_end] = self::getRangeStartAndEnd($p_fromDateTime, $p_toDateTime); $data = []; $data['media'] = []; // Harbor kick times *MUST* be ahead of schedule events, so that pypo // executes them first. self::createInputHarborKickTimes($data, $range_start, $range_end); self::createScheduledEvents($data, $range_start, $range_end); // self::foldData($data["media"]); return $data; } public static function deleteAll() { $sql = 'TRUNCATE TABLE cc_schedule'; Application_Common_Database::prepareAndExecute( $sql, [], Application_Common_Database::EXECUTE ); } public static function deleteWithFileId($fileId) { $sql = 'DELETE FROM cc_schedule WHERE file_id=:file_id'; Application_Common_Database::prepareAndExecute($sql, [':file_id' => $fileId], 'execute'); } public static function checkOverlappingShows( $show_start, $show_end, $update = false, $instanceId = null, $showId = null ) { // if the show instance does not exist or was deleted, return false if (!is_null($showId)) { $ccShowInstance = CcShowInstancesQuery::create() ->filterByDbShowId($showId) ->filterByDbStarts($show_start->format(DEFAULT_TIMESTAMP_FORMAT)) ->findOne(); } elseif (!is_null($instanceId)) { $ccShowInstance = CcShowInstancesQuery::create() ->filterByDbId($instanceId) ->findOne(); } if ($update && ($ccShowInstance && $ccShowInstance->getDbModifiedInstance() == true)) { return false; } $overlapping = false; $params = [ ':show_end1' => $show_end->format(DEFAULT_TIMESTAMP_FORMAT), ':show_end2' => $show_end->format(DEFAULT_TIMESTAMP_FORMAT), ':show_end3' => $show_end->format(DEFAULT_TIMESTAMP_FORMAT), ]; /* If a show is being edited, exclude it from the query * In both cases (new and edit) we only grab shows that * are scheduled 2 days prior */ if ($update) { $sql = <<<'SQL' SELECT id, starts, ends FROM cc_show_instances WHERE (ends <= :show_end1 OR starts <= :show_end2) AND date(starts) >= (date(:show_end3) - INTERVAL '2 days') AND modified_instance = FALSE SQL; if (is_null($showId)) { $sql .= <<<'SQL' AND id != :instanceId ORDER BY ends SQL; $params[':instanceId'] = $instanceId; } else { $sql .= <<<'SQL' AND show_id != :showId ORDER BY ends SQL; $params[':showId'] = $showId; } $rows = Application_Common_Database::prepareAndExecute($sql, $params, 'all'); } else { $sql = <<<'SQL' SELECT id, starts, ends FROM cc_show_instances WHERE (ends <= :show_end1 OR starts <= :show_end2) AND date(starts) >= (date(:show_end3) - INTERVAL '2 days') AND modified_instance = FALSE ORDER BY ends SQL; $rows = Application_Common_Database::prepareAndExecute($sql, [ ':show_end1' => $show_end->format(DEFAULT_TIMESTAMP_FORMAT), ':show_end2' => $show_end->format(DEFAULT_TIMESTAMP_FORMAT), ':show_end3' => $show_end->format(DEFAULT_TIMESTAMP_FORMAT), ], 'all'); } foreach ($rows as $row) { $start = new DateTime($row['starts'], new DateTimeZone('UTC')); $end = new DateTime($row['ends'], new DateTimeZone('UTC')); if ( $show_start->getTimestamp() < $end->getTimestamp() && $show_end->getTimestamp() > $start->getTimestamp() ) { $overlapping = true; break; } } return $overlapping; } public static function GetType($p_scheduleId) { $scheduledItem = CcScheduleQuery::create()->findPK($p_scheduleId); if ($scheduledItem->getDbFileId() == null) { return 'webstream'; } return 'file'; } public static function GetFileId($p_scheduleId) { $scheduledItem = CcScheduleQuery::create()->findPK($p_scheduleId); return $scheduledItem->getDbFileId(); } public static function GetStreamId($p_scheduleId) { $scheduledItem = CcScheduleQuery::create()->findPK($p_scheduleId); return $scheduledItem->getDbStreamId(); } }