Source code for TapasPlayer

#!/usr/bin/env python
# -*- Mode: Python -*-
# -*- encoding: utf-8 -*-
# Copyright (c) Vito Caldaralo <vito.caldaralo@gmail.com>

# This file may be distributed and/or modified under the terms of
# the GNU General Public License version 2 as published by
# the Free Software Foundation.
# This file is distributed without any warranty; without even the implied
# warranty of merchantability or fitness for a particular purpose.
# See "LICENSE" in the source distribution for more information.
import os, sys, inspect
from twisted.internet import defer, reactor
import time, datetime
from pprint import pformat
from utils_py.util import debug, format_bytes, Logger, getPage, send_json, makeJsonUrl, RateCalc, ProcessStats
from utils_py.connection import parse_url, ClientFactory

DEBUG = 2
USER_AGENT = 'Mozilla/5.0 (iPad; PythonHlsPlayer 0.1) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B334b Safari/531.21.10'

[docs]class TapasPlayer(object): def __init__(self, controller, parser, media_engine, log_sub_dir='', log_period=0.1, max_buffer_time=60, inactive_cycle=1, initial_level=1, use_persistent_connection=True, check_warning_buffering=True, stress_test=False): # player components self.controller = controller self.parser = parser self.media_engine = media_engine # log self.logger = None self.log_file = None self.log_dir='logs' self.log_sub_dir=log_sub_dir self.log_period = log_period self.log_prefix='' self.log_comment='' # self.max_buffer_time = max_buffer_time self.inactive_cycle = inactive_cycle #active control action after inactive_cycle+1 segments # self.use_persistent_connection = use_persistent_connection self.connection = None # self.cur_level = initial_level self.cur_index = 0 self.enable_stress_test = stress_test #flag to enable stress test, switch level every segment cyclically # self.check_warning_buffering = check_warning_buffering #flag to enable check for warning buffering self.rate_calc = RateCalc(period=3.0, alpha=0.0) self.remaining_data = 0 # self.bwe = 0 self.downloaded_bytes = 0 self.downloaded_segments = 0 self.last_fragment_size = 0 self.start_segment_request = -1.0 self.stop_segment_request = -1.0 self.last_downloaded_time = -1.0 self.t_paused = time.time() self.paused_time = 0.0 self.queuedBytes = 0 self.queuedTime = 0.0 # self.proc_stats = ProcessStats() #Initialize the parameters passed at the controller after the download of every segment self.feedback = dict(queued_bytes=0, queued_time=0.0, max_buffer_time=self.max_buffer_time, bwe=0.0, level=self.cur_level, max_level=-1, cur_rate=0.0, max_rate=0.0, min_rate=0.0, player_status=0, paused_time=0.0, last_fragment_size=0, last_download_time=0.0, downloaded_bytes=0, fragment_duration=0.0, rates=[] ) self.controller.setPlayerFeedback(self.feedback) def __repr__(self): return '<TapasPlayer-%d>' %id(self)
[docs] def play(self): ''' Starts Parser, creates Logger, initializes MediaEngine, and fetches the first segment when the parser has finished ''' self.parser.loadPlaylist() def _on_done(res): playlists = self.parser.getPlaylists() levels = self.parser.getLevels() fragment_duration = self.parser.getFragmentDuration() caps = self.parser._getCapsDemuxer() self.controller.setIdleDuration(fragment_duration) #Default pause interval when isBuffering return False if self.getCurrentLevel() > self.getMaxLevel() or self.getCurrentLevel() == -1: self.setCurrentLevel(self.getMaxLevel()) #opts for Logger opts = [ ('enqueued_b', int, ''), #2 ('enqueued_t', float, 'visible=1,subplot=2'), #3 ('bwe', float, 'visible=1,subplot=1'), #4 ('cur', int, 'visible=1,subplot=1'), #5 ('level', int, 'visible=1,subplot=3'), #6 ('max_level', int, ''), #7 ('player_status', int, 'visible=1,subplot=3'), #8 ('paused_time', float, ''), #9 ('downloaded_bytes', int, ''), #10 ('cpu', float, 'visible=1,subplot=4'), #11 ('mem', float, 'visible=1,subplot=5'), #12 ('rss', float, ''), #13 ('vms', float, ''), #14 ('ts_start_req', float, ''), #15 ('ts_stop_req', float, ''), #16 ] for i in range(0,len(levels)): opts.append(('q%d' %i, int, 'visible=0')) if self.log_sub_dir: self.log_dir = self.log_dir + '/'+ self.log_sub_dir #Create Logger self.logger = Logger(opts, log_period=self.log_period, log_prefix=self.log_prefix, comment=self.log_comment, log_dir=self.log_dir) debug(DEBUG+1, 'levels: %s', levels) debug(DEBUG+1, 'playlists: %s', playlists) if self.enable_stress_test: self.inactive_cycle = 0 if self.check_warning_buffering: self.rate_calc.start() self.rate_calc.connect('update', self.checkBuffering) #Init media_engine self.media_engine.setVideoContainer(self.parser.getVideoContainer()) self.media_engine.connect('status-changed', self._onStatusChanged) self.media_engine.start() #start logger reactor.callLater(self.log_period, self.log) # self.fetchNextSegment() self.parser.deferred.addCallback(_on_done)
[docs] def getMaxBufferTime(self): ''' Gets max buffer in seconds under which the playback is considered in Buffering by default ''' return self.max_buffer_time
[docs] def getCurrentLevel(self): ''' Gets index of current level starting from 0 for the lowest video quality level ''' return self.cur_level
[docs] def setCurrentLevel(self,level): ''' Sets index of current level starting from 0 for the lowest video quality level :param level: the level index ''' self.cur_level = level
[docs] def getMaxLevel(self): ''' Gets index of maximum level starting from 0 for the lowest video quality level ''' return len(self.parser.getLevels())-1
[docs] def getCurrentSegmentIndex(self): ''' Gets index of the current segment of the sub-playlist ''' return self.cur_index
[docs] def setCurrentSegmentIndex(self,index): ''' Sets index of the current segment of the sub-playlist :param index: segment index ''' self.cur_index = index
[docs] def getCurrentRate(self): ''' Gets current video quality level rate in B/s ''' levels = self.parser.getLevels() cur_rate = float(levels[self.getCurrentLevel()]['rate']) return cur_rate
[docs] def getMaxRate(self): ''' Gets maximum video quality level rate in B/s ''' levels = self.parser.getLevels() rates = [float(i['rate']) for i in levels] return max(rates)
[docs] def getMinRate(self): ''' Gets minimum video quality level rate in B/s ''' levels = self.parser.getLevels() rates = [float(i['rate']) for i in levels] return min(rates)
[docs] def getLevelRates(self): ''' Gets a list of video quality level rates in B/s ''' levels = self.parser.getLevels() _r= [float(i['rate']) for i in levels] rates = [] for i in range(0,len(_r)): rates.append(_r[i]) return rates
[docs] def getLevelResolutions(self): ''' Gets a list of available video resolutions ''' levels = self.parser.getLevels() resolutions = [i['resolution'] for i in levels] return resolutions
[docs] def getLastDownloadedTime(self): ''' Gets time spent to download the last segment ''' return self.last_downloaded_time
[docs] def getStartSegmentRequest(self): ''' Gets timestamp when starts the download of the last segment ''' return self.start_segment_request
[docs] def getStopSegmentRequest(self): ''' Gets timestamp when stops the download of the last segment ''' return self.stop_segment_request
[docs] def getLastFragmentBytes(self): ''' Gets the last fragment size in B ''' return self.last_fragment_size
[docs] def getDownloadedBytes(self): ''' Gets total downloaded bytes in B ''' return self.downloaded_bytes
[docs] def getDownloadedSegments(self): ''' Gets total number of downloaded segments ''' return self.downloaded_segments
[docs] def getBandwidth(self): ''' Gets last estimated available bandwidth in B/s ''' return self.bwe
[docs] def getPausedTime(self): ''' Gets time spent on pause ''' return self.paused_time
[docs] def getInactiveCycles(self): ''' Gets the number of inactive cycles before activate the control action ''' return self.inactive_cycle
[docs] def getLogFileName(self): ''' Gets log file name ''' return self.log_file
[docs] def fetchNextSegment(self): ''' Schedules the download of the next segment at current level ''' playlist = self.parser.playlists[self.getCurrentLevel()] debug(DEBUG+1, '%s fetchNextSegment level: %d cur_index: %d', self, self.getCurrentLevel(), self.getCurrentSegmentIndex()) # if self.getCurrentSegmentIndex() < playlist['start_index']: self.setCurrentSegmentIndex(playlist['start_index']) if self.getCurrentSegmentIndex() > playlist['end_index']: # else live video (ONLY HLS!!) if playlist['is_live'] and self.parser.getPlaylistType() == 'HLS': debug(DEBUG, '%s fetchNextSegment cur_index %d', self, self.getCurrentSegmentIndex()) self.parser.updateLevelSegmentsList(self.getCurrentLevel()).addCallback(self._updatePlaylistDone) # if video is vod else: debug(DEBUG, '%s fetchNextSegment last index', self) return cur_index=self.getCurrentSegmentIndex() levels = self.parser.getLevels() url_segment = playlist['segments'][cur_index]['url'] byterange = playlist['segments'][cur_index]['byterange'] if byterange != '': debug(DEBUG, '%s fetchNextSegment level: %d (%s/s) %d/%d : %s (byterange=%s)', self, self.getCurrentLevel(), format_bytes(float(levels[self.getCurrentLevel()]['rate'])), self.getCurrentSegmentIndex(), playlist['end_index'], url_segment, byterange) else: debug(DEBUG, '%s fetchNextSegment level: %d (%s/s) %d/%d : %s', self, self.getCurrentLevel(), format_bytes(float(levels[self.getCurrentLevel()]['rate'])), self.getCurrentSegmentIndex(), playlist['end_index'], url_segment) if self.controller.isBuffering(): idle_duration = 0.0 #fetch segment after the last segment download is completed else: idle_duration = self.controller.getIdleDuration() # load the next segment reactor.callLater(idle_duration, self.startDownload, url_segment, byterange)
[docs] def startDownload(self, url, byterange=''): ''' Starts the segment download and set the timestamp of start segment download :param url: segment url :param byterange: segment byterange (logical segmentation of video level) ''' debug(DEBUG+1, '%s startDownload %s (byterange %s)', self, url, byterange) # start download if self.use_persistent_connection: # start a new connection if not self.connection: self._initConnection(url) return if not self.connection.client: return _, _, path = parse_url(url) self.connection.makeRequest(path, byterange) else: if byterange == '': d = getPage(url, agent=USER_AGENT) else: d = getPage(url, agent=USER_AGENT, headers=dict(range='bytes='+byterange)) d.deferred.addCallback(self.playNextGotRequest, d) d.deferred.addErrback(self.playNextGotError, d) self.start_segment_request = time.time() # callback if do not use persistent connection
[docs] def playNextGotRequest(self, data, factory): ''' Updates feedbacks, calculates the control action and sets level of the next segment. :param data: downloaded data :param factory: the twisted factory (used without persistent connection) ''' self.stop_segment_request = time.time() download_time = (self.stop_segment_request - self.start_segment_request) self.last_downloaded_time = download_time self.bwe = len(data)/download_time self.last_fragment_size = len(data) self.downloaded_bytes += len(data) self.downloaded_segments += 1 debug(DEBUG, '%s __got_request: bwe: %s/s (fragment size: %s)', self, format_bytes(self.bwe), format_bytes(len(data))) self.queuedTime = self.media_engine.getQueuedTime() + self.parser.getFragmentDuration() self.queuedBytes = self.media_engine.getQueuedBytes() + len(data) self.media_engine.pushData(data, self.parser.getFragmentDuration(), self.getCurrentLevel(), self.parser._getCapsDemuxer()) del data self.cur_index += 1 #Do something before calculating new control action self._onNewSegment() #Passing player parameters at the controller to calculate the control action self.updateFeedback(flag_check_buffering=False) #calc control action self.controller.setControlAction(self.controller.calcControlAction()) # set new level if self.getDownloadedSegments() > self.getInactiveCycles(): if self.enable_stress_test: self.stressTest() else: self.setLevel(self.controller.getControlAction()) self.fetchNextSegment() # error handling if do not use persistent connection
[docs] def playNextGotError(self, error, factory): ''' Handles error when download a segment without persistent connection :param error: the occurred error :param factory: the twisted factory (used without persistent connection) ''' debug(0, '%s playNextGotError url: %s error: %s', self, factory.url, error) # update playlist if self.parser.getPlaylistType()=='HLS': self.parser.updateLevelSegmentsList(self.cur_level).addCallback(self._updatePlaylistDone)
[docs] def setLevel(self, rate): ''' Sets the level corresponding to the rate specified in B/s :param rate: rate in B/s that determines the level. The level is the one whose rate is the highest below ``rate``. ''' new_level = self.controller.quantizeRate(rate) if new_level != self.getCurrentLevel(): debug(DEBUG, "%s setLevel: level: %d", self, new_level) self.setCurrentLevel(new_level) #self.onLevelChanged() return new_level
[docs] def stressTest(self): ''' Switches the video quality level cyclically every segment ''' self.check_warning_buffering = False if self.getCurrentLevel() == self.getMaxLevel(): new_level = 0 else: new_level = self.getCurrentLevel() + 1 self.setCurrentLevel(new_level) #self.onLevelChanged() return new_level
[docs] def checkBuffering(self, _arg): ''' Checks if the playback is going to buffering. Estimates the time required to complete the download of the current segment and verifies that it is less than the playout buffer lenght. In the case of "warning buffering", it deletes the current segment download, calculates the control action and sets the new level. This feature is available only with persistent connection. ''' #FIXME Can't use it without persistent connection if self.rate_calc.rate and self.cur_index > self.inactive_cycle: remaining_secs = float(self.remaining_data/self.rate_calc.rate) debug(DEBUG+1,"%s checkBuffering: rate %s/s, remaining_data %s, remaining_secs %.3f, queued_time %.2f ", self, format_bytes(self.rate_calc.rate), format_bytes(self.remaining_data), remaining_secs, self.media_engine.getQueuedTime()) #Can cancel download only if the current level is greater than 0 if self.media_engine.getQueuedTime() < remaining_secs and self.getCurrentLevel() > 0: self.connection.stop() self.stop_segment_request = time.time() #update stop reqest when warning buffering occurs self.bwe = self.rate_calc.rate #Passing player parameters at the controller to calculate the control action self.updateFeedback(flag_check_buffering=True) #calc control action self.controller.setControlAction(self.controller.calcControlAction()) # set new level if self.cur_index > self.inactive_cycle: self.setLevel(self.controller.getControlAction()) debug(0,"%s WARNING BUFFERING!!! Delete and reload segment at level: %d", self, self.getCurrentLevel()) else: return
[docs] def updateFeedback(self, flag_check_buffering): ''' Updates dictionary of feedbacks before passing it to the controller. :param flag_check_buffering: true if this method is called from ``checkBuffering``. False otherwise. ''' self.feedback = dict(queued_bytes=self.media_engine.getQueuedBytes(), queued_time=self.media_engine.getQueuedTime(), max_buffer_time=self.getMaxBufferTime(), bwe=self.getBandwidth(), level=self.getCurrentLevel(), max_level=self.getMaxLevel(), cur_rate=self.getCurrentRate(), max_rate=self.getMaxRate(), min_rate=self.getMinRate(), player_status=self.media_engine.getStatus(), paused_time=self.getPausedTime(), last_fragment_size=self.getLastFragmentBytes(), last_download_time=self.getLastDownloadedTime(), downloaded_bytes=self.getDownloadedBytes(), fragment_duration=self.parser.getFragmentDuration(), rates=self.getLevelRates(), is_check_buffering=flag_check_buffering ) self.controller.setPlayerFeedback(self.feedback)
[docs] def log(self): ''' Logs useful metrics every ``log_period`` seconds ''' if not self.logger: return stats = self.proc_stats.getStats() opts = dict( enqueued_b=self.media_engine.getQueuedBytes(), #2 enqueued_t=self.media_engine.getQueuedTime(), #3 bwe=self.getBandwidth(), #4 cur=self.getCurrentRate(), #5 level=self.getCurrentLevel(), #6 max_level=self.getMaxLevel(), #7 player_status=self.media_engine.getStatus(), #8 paused_time=self.getPausedTime(), #9 downloaded_bytes=self.getDownloadedBytes(), #10 cpu=stats["cpu_percent"], #11 mem=stats["memory_percent"], #12 rss=stats["memory_rss"], #13 vms=stats["memory_vms"], #14 ts_start_req=self.getStartSegmentRequest(), #15 ts_stop_req=self.getStopSegmentRequest(), #16 ) levels = self.parser.getLevels() for i in range(0,len(self.getLevelRates())): opts['q%d' %i] = float(levels[i]['rate']) self.logger.log(opts) del opts reactor.callLater(self.log_period, self.log)
def _onNewSegment(self): ''' Does something before calculating new control action ''' pass # callback def _onDataReceiving(self, connection, data_diff, remaining_data): ''' Does something before segment download is completed (used with persistent connection) ''' self.remaining_data = remaining_data debug(DEBUG+1, '%s _onDataReceiving: %s %s', self, format_bytes(data_diff), format_bytes(remaining_data)) self.rate_calc.update(data_diff) # callback def _onDataReceived(self, connection, data): ''' Does something when segment download is completed (used with persistent connection) ''' debug(DEBUG+1, '%s _onDataReceived: %s', self, format_bytes(len(data))) self.playNextGotRequest(data, None) def _initConnection(self, url): ''' Initializes connection with url (only with persistent connection) ''' if self.connection: self.connection.stop() debug(DEBUG+1, '%s _initConnection: %s', self, url) self.connection = ClientFactory(url) self.connection.connect('connection-made', self._onConnectionMade) self.connection.connect('connection-lost', self._onConnectionLost) self.connection.connect('data-received', self._onDataReceived) self.connection.connect('data-receiving', self._onDataReceiving) def _onConnectionMade(self, connection, host): ''' Does something when connection with host is established (only with persistent connection). ''' debug(DEBUG+1, '%s _onConnectionMade: %s', self, host) if self.logger: self.logger.log_comment('Host: %s' %host) reactor.callLater(0.1, self.fetchNextSegment) def _onConnectionLost(self, connection): ''' Does something when connection with host is lost (only with persistent connection). ''' self.connection = None if self.parser.getPlaylistType()=='HLS': debug(0, '%s _onConnectionLost', self) self.parser.updateLevelSegmentsList(self.cur_level).addCallback(self._updatePlaylistDone) else: #only for youtube (for check buffering) debug(DEBUG, '%s _onConnectionLost', self) self.fetchNextSegment() #When status changes calls 'onPlaying' and 'onPaused' hooks #callback def _onStatusChanged(self, media_engine): ''' Does something when player status change from play to pause and viceversa ''' if media_engine.status == media_engine.PLAYING: self.controller.onPlaying() self.paused_time += time.time() - self.t_paused else: self.controller.onPaused() self.t_paused = time.time() #callback def _updatePlaylistDone(self, data): ''' Called when the playlist for the current level is update ''' playlist = self.parser.playlists[self.getCurrentLevel()] debug(DEBUG+1, '%s playlist: %s', self, pformat(playlist)) # start play if playlist has more than 2 fragments if len(playlist['segments']) > 2 and self.getCurrentSegmentIndex() < playlist['end_index']: self.fetchNextSegment() else: reactor.callLater(self.parser.getFragmentDuration(), self.fetchNextSegment)