├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.rst ├── docs └── MMSys2020-paper.pdf ├── images └── config-parameters.png ├── load_generator ├── __init__.py ├── common │ ├── __init__.py │ ├── dash_emulation.py │ ├── dash_utils.py │ └── hls_emulation.py ├── config │ ├── __init__.py │ └── default.py └── locustfiles │ ├── __init__.py │ ├── dash_sequence.py │ ├── hls_player.py │ └── vod_dash_hls_sequence.py ├── pytest.ini ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── manifest_samples └── manifest.mpd └── test_vod.py /.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | streamin_load_testing.egg-info 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine3.13 2 | LABEL maintainer "Roberto Ramos " 3 | 4 | # Install packages 5 | RUN apk --no-cache add g++ zeromq-dev libffi-dev file make gcc musl-dev \ 6 | && rm -f /var/cache/apk/* 7 | 8 | # Copy locust and load emulation requirements 9 | COPY requirements.txt / 10 | COPY setup.py / 11 | COPY README.rst / 12 | COPY load_generator /load_generator 13 | 14 | 15 | RUN apk add --no-cache -U --virtual build-deps \ 16 | g++ \ 17 | && python -m pip install --upgrade pip \ 18 | && pip3 install -r /requirements.txt \ 19 | && apk del build-deps \ 20 | && rm -f /var/cache/apk/* 21 | 22 | WORKDIR /load_generator 23 | 24 | EXPOSE 8089 5557 25 | 26 | 27 | ENTRYPOINT ["locust"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Unified Streaming 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROGRAM_FILES=./load_generator/*.py 2 | TEST_FILES=./tests/*.py 3 | VOD_CONTENT_FOLDER=content/vod-tos 4 | LIVE_DOCKER_COMPOSE=content/live-demo/docker-compose.yml 5 | UNIFIED_CAPTURE_FOLDER=./tests/unified_capture 6 | OUTPUT_CAPTURE = urls_output.txt 7 | ORIGIN_TAG=1.10.18 8 | 9 | ifndef TAG 10 | TAG=latest 11 | endif 12 | 13 | all: test 14 | 15 | 16 | .PHONY: init 17 | init: 18 | pip3 install -r requirements.txt 19 | mkdir -p test-results 20 | 21 | 22 | .PHONY: upload 23 | upload: 24 | python3 setup.py upload 25 | 26 | # Coverage3 test 27 | tests: $(TEST_FILES) $(PROGRAM_FILES) 28 | coverage3 run -m pytest 29 | 30 | download_vod: 31 | wget "http://repository.unified-streaming.com/tears-of-steel.zip" -O tos.zip 32 | mkdir -p $(VOD_CONTENT_FOLDER) 33 | unzip tos.zip -d $(VOD_CONTENT_FOLDER) 34 | 35 | vod_origin: 36 | docker run --rm -e USP_LICENSE_KEY=\${USP_LICENSE_KEY} \ 37 | -e DEBUG_LEVEL=warn -v ${PWD}/$(VOD_CONTENT_FOLDER):/var/www/unified-origin \ 38 | -p 80:80 unifiedstreaming/origin:${ORIGIN_TAG} 39 | 40 | live_origin: 41 | docker-compose -f $(LIVE_DOCKER_COMPOSE) up 42 | 43 | 44 | dry_run_unified_capture: 45 | rm -f $(UNIFIED_CAPTURE_FOLDER)/$(OUTPUT_CAPTURE) 46 | mkdir -p $(UNIFIED_CAPTURE_FOLDER) 47 | docker run --rm --entrypoint unified_capture -v ${PWD}:/data -w /data/ \ 48 | unifiedstreaming/packager:$(ORIGIN_TAG) \ 49 | --license-key=$(USP_LICENSE_KEY) -o $(UNIFIED_CAPTURE_FOLDER)/output.mp4 \ 50 | https://demo.unified-streaming.com/video/ateam/ateam.ism/ateam.mpd \ 51 | --dry_run >> $(UNIFIED_CAPTURE_FOLDER)/$(OUTPUT_CAPTURE) 52 | 53 | 54 | .PHONY: env 55 | 56 | env/bin/activate: 57 | test -d env || python3 -m venv env 58 | . env/bin/activate; \ 59 | pip3 install wheel; \ 60 | pip3 install -Ur requirements.txt 61 | 62 | env: env/bin/activate 63 | 64 | test: env 65 | . env/bin/activate; coverage run -m pytest -s 66 | 67 | .PHONY: build 68 | 69 | build: 70 | time docker build -t unifiedstreaming/load-generator:$(TAG) . 71 | 72 | 73 | .PHONY: docker-stop 74 | 75 | docker-stop: 76 | docker stop unified-streaming/streaming-load-testing 77 | docker container prune -f 78 | 79 | 80 | .PHONY: run 81 | 82 | run: 83 | ifneq ($(and $(ORIGIN), $(MANIFEST_FILE), $(LOCUST_FILE)}),) 84 | docker run \ 85 | -e "HOST_URL=http://${ORIGIN}" \ 86 | -e "MANIFEST_FILE=${MANIFEST_FILE}" \ 87 | -e "mode=vod" \ 88 | -e "play_mode=full_playback" \ 89 | -e "bitrate=lowest_bitrate" \ 90 | -p 8089:8089 \ 91 | -v ${PWD}/test-results/:/test-results/ \ 92 | unified-streaming/streaming-load-testing \ 93 | -f /load_generator/locustfiles/${LOCUST_FILE} \ 94 | --no-web -c 1 -r 1 --run-time 10s --only-summary \ 95 | --csv=../test-results/ouput_example 96 | else 97 | docker run \ 98 | -e "HOST_URL=https://demo.unified-streaming.com" \ 99 | -e "MANIFEST_FILE=/video/ateam/ateam.ism/ateam.m3u8" \ 100 | -e "mode=vod" \ 101 | -e "play_mode=full_playback" \ 102 | -e "bitrate=lowest_bitrate" \ 103 | -p 8089:8089 \ 104 | -v ${PWD}/test-results/:/test-results/ \ 105 | unified-streaming/streaming-load-testing \ 106 | -f /load_generator/locustfiles/vod_dash_hls_sequence.py \ 107 | --no-web -c 1 -r 1 --run-time 10s --only-summary \ 108 | --csv=../test-results/ouput_example 109 | endif 110 | 111 | 112 | 113 | clean: 114 | rm -rf env 115 | rm -rf test-results 116 | find . -iname "*pycache*" -delete 117 | 118 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Streaming load testing 2 | ====================== 3 | Load generation tool for evaluation of MPEG-DASH and HLS video streaming 4 | setups. This project is an extension of `Locust`_ open source software. 5 | This extension is **NOT** a product/solution by Unified Streaming. 6 | 7 | Docs: `MMSys2020 paper`_ 8 | 9 | .. _`MMSys2020 paper`: docs/MMSys2020-paper.pdf 10 | .. _`Locust`: https://locust.io/ 11 | 12 | .. contents:: Table of Contents 13 | :local: 14 | :depth: 2 15 | 16 | 17 | What is Locust 18 | -------------- 19 | 20 | Locust is an open source tool for load testing of web applications. The 21 | software is optimized to support the emulation of a large number of users, 22 | without using a large number of threads by using an event-driven model. Locust 23 | allows writing specific user behaviour for load testing through plain Python 24 | programming language scripts. 25 | 26 | 27 | How to use the tool 28 | -------------------- 29 | The following figure presents the possible command-line parameter configuration 30 | of the load testing tool. 31 | 32 | .. |fig1| image:: images/config-parameters.png 33 | :width: 40% 34 | :align: middle 35 | :alt: Configuration parameters for streaming load-generator. 36 | 37 | +-----------------------------------------+ 38 | | |fig1| | 39 | +-----------------------------------------+ 40 | | Configuration parameters | 41 | +-----------------------------------------+ 42 | 43 | 44 | Use the tool in a hypervisor 45 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 46 | In case you need to run the Python scripts from a VM you will need to 47 | clone the repository and install the Python requirements.txt by running the 48 | following commands. 49 | 50 | .. code-block:: bash 51 | 52 | #!/bin/bash 53 | git clone https://github.com/unifiedstreaming/streaming-load-testing.git 54 | cd streaming-load-testing 55 | make init 56 | 57 | 58 | Load test example 59 | """"""""""""""""" 60 | .. code-block:: bash 61 | 62 | #!/bin/bash 63 | 64 | HOST_URL=https://demo.unified-streaming.com \ 65 | MANIFEST_FILE=/video/ateam/ateam.ism/ateam.mpd \ 66 | mode=vod \ 67 | play_mode=full_playback \ 68 | bitrate=lowest_bitrate \ 69 | locust -f load_generator/locustfiles/vod_dash_hls_sequence.py \ 70 | --no-web -c 1 -r 1 --run-time 10s --only-summary \ 71 | --csv=test-results/output_example 72 | 73 | 74 | 75 | Use the tool through a Docker image 76 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 77 | 78 | Create Docker image by building the Docker file provided. 79 | 80 | .. code-block:: bash 81 | 82 | #!/bin/bash 83 | git clone https://github.com/unifiedstreaming/streaming-load-testing.git 84 | cd streaming-load-testing 85 | make build 86 | 87 | Run a simple load testing example using the built docker image. The following 88 | command will create a performance test and creates a folder with ``csv`` files 89 | results in the folder test-results. 90 | 91 | Load test example 92 | """"""""""""""""" 93 | 94 | .. code-block:: bash 95 | 96 | #!/bin/bash 97 | 98 | docker run -it \ 99 | -e "HOST_URL=https://demo.unified-streaming.com" \ 100 | -e "MANIFEST_FILE=/video/ateam/ateam.ism/ateam.mpd" \ 101 | -e "mode=vod" \ 102 | -e "play_mode=full_playback" \ 103 | -e "bitrate=lowest_bitrate" \ 104 | -e "LOCUST_LOCUSTFILE=/load_generator/locustfiles/vod_dash_hls_sequence.py" \ 105 | -e "LOCUST_HEADLESS=true" \ 106 | -e "LOCUST_USERS=1" \ 107 | -e "LOCUST_SPAWN_RATE=1" \ 108 | -e "LOCUST_RUN_TIME=2s" \ 109 | -e "LOCUST_ONLY_SUMMARY=true" \ 110 | -p 8089:8089 \ 111 | unifiedstreaming/load-generator:latest \ 112 | --no-web 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /docs/MMSys2020-paper.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unifiedstreaming/streaming-load-testing/3bef149fff58238956789e9615d1f90ce3cfd1ee/docs/MMSys2020-paper.pdf -------------------------------------------------------------------------------- /images/config-parameters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unifiedstreaming/streaming-load-testing/3bef149fff58238956789e9615d1f90ce3cfd1ee/images/config-parameters.png -------------------------------------------------------------------------------- /load_generator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unifiedstreaming/streaming-load-testing/3bef149fff58238956789e9615d1f90ce3cfd1ee/load_generator/__init__.py -------------------------------------------------------------------------------- /load_generator/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unifiedstreaming/streaming-load-testing/3bef149fff58238956789e9615d1f90ce3cfd1ee/load_generator/common/__init__.py -------------------------------------------------------------------------------- /load_generator/common/dash_emulation.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | from locust import TaskSequence, seq_task, TaskSet, task 5 | from mpegdash.parser import MPEGDASHParser 6 | from load_generator.common import dash_utils 7 | from load_generator.config import default 8 | import random 9 | from locust.exception import StopLocust 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | MANIFEST_FILE = os.getenv('MANIFEST_FILE') 14 | PLAY_MODE = os.getenv("play_mode") 15 | BUFFER_SIZE = os.getenv("buffer_size") 16 | BUFFER_SIZE = int(BUFFER_SIZE) # Cast os.environ str to int 17 | BITRATE = os.getenv("bitrate") 18 | 19 | LOGGER = True 20 | 21 | 22 | class class_dash_player(TaskSet): 23 | """ 24 | Simple MPEG-DASH emulation of a player 25 | Receives an MPEG-DASH /.mpd manifest 26 | """ 27 | base_url = None 28 | mpd_body = None 29 | mpd_object = None 30 | print("started task") 31 | 32 | @seq_task(1) 33 | def get_manifest(self): 34 | print("MPEG-DASH child player running ...") 35 | base_url = f"{self.locust.host}/{MANIFEST_FILE}" 36 | if LOGGER: 37 | print(base_url) 38 | self.base_url = f"{base_url}" # It should already be a /.mpd 39 | logger.info(f"Requesting manifest: {base_url}") 40 | response_mpd = self.client.get(f"{base_url}", name="merged") 41 | self.mpd_body = response_mpd.text 42 | if response_mpd.status_code == 0 or response_mpd.status_code == 404: 43 | logger.info("Make sure your Manifest URI is reachable") 44 | try: 45 | sys.exit(1) 46 | except SystemExit: 47 | os._exit(1) 48 | else: 49 | pass 50 | 51 | @seq_task(2) 52 | def dash_parse(self, reschedule=True): 53 | """ 54 | Parse Manifest file to MPEGDASHParser 55 | """ 56 | logger.info("Obtained MPD body ") 57 | if self.mpd_body is not None: 58 | self.mpd_object = MPEGDASHParser.parse(self.mpd_body) 59 | print(f"self.mpd_object: {self.mpd_object}") 60 | else: 61 | # self.interrupt() 62 | pass 63 | 64 | @seq_task(3) 65 | def dash_playback(self): 66 | """ 67 | Create a list of the avaialble segment URIs with 68 | its specific media representation 69 | """ 70 | logger.info("Dash playback") 71 | all_reprs, period_segments = dash_utils.prepare_playlist( 72 | self.base_url, self.mpd_object 73 | ) 74 | if all_reprs != 0 and period_segments != 0: 75 | selected_representation = dash_utils.select_representation( 76 | period_segments["abr"], 77 | BITRATE # highest_bitrate, lowest_bitrate, random_bitrate 78 | ) 79 | chosen_video = selected_representation[1] 80 | chosen_audio = selected_representation[0] 81 | if PLAY_MODE == "full_playback": 82 | if BUFFER_SIZE == 0: 83 | dash_utils.simple_playback( 84 | self, 85 | period_segments, 86 | chosen_video, 87 | chosen_audio, 88 | False # Delay in between every segment request 89 | ) 90 | else: 91 | dash_utils.playback_w_buffer( 92 | self, 93 | period_segments, 94 | chosen_video, 95 | chosen_audio, 96 | BUFFER_SIZE 97 | ) 98 | elif PLAY_MODE == "only_manifest": 99 | self.get_manifest() 100 | else: 101 | # select random segments: one for audio content and second for 102 | # video 103 | video_timeline = period_segments["repr"][chosen_video]["timeline"] 104 | audio_timeline = period_segments["repr"][chosen_audio]["timeline"] 105 | video_segment = random.choice(video_timeline) 106 | audio_segment = random.choice(audio_timeline) 107 | logger.info(video_segment["url"]) 108 | self.client.get(video_segment["url"]) 109 | logger.info(audio_segment["url"]) 110 | self.client.get(audio_segment["url"]) 111 | else: 112 | print("Peridos not found in the MPD body") 113 | -------------------------------------------------------------------------------- /load_generator/common/dash_utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | import logging 4 | import random 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | HIGHEST_BITRATE = "highest_bitrate" 10 | LOWEST_BITRATE = "lowest_bitrate" 11 | 12 | LOGGER_SEGMENTS = True 13 | 14 | 15 | def get_segment_url(ism_url, base_url, media_segment): 16 | return( 17 | f"{ism_url}/{base_url}{media_segment}" 18 | ) 19 | 20 | 21 | def create_segment_timeline( 22 | ism_url, base_url, media_segment, time, segment_duration): 23 | url = get_segment_url(ism_url, base_url, media_segment) 24 | segment = {} 25 | segment["time"] = time 26 | segment["url"] = url 27 | segment["duration"] = segment_duration 28 | return segment 29 | 30 | 31 | def create_segment( 32 | media_segment, ism_url, protocol, time, segment_duration, 33 | segment_timeline): 34 | 35 | segment = create_segment_timeline( 36 | ism_url, 37 | protocol, 38 | media_segment, 39 | time, 40 | segment_duration 41 | ) 42 | segment_timeline.append(segment) 43 | time = time + segment_duration 44 | return time, segment_timeline 45 | 46 | 47 | def create_segments_timeline( 48 | ism_url, protocol, media, representation, timeline): 49 | """ 50 | host: string 51 | ism_file: string 52 | base_url: string 53 | representation: string 54 | timeline: SegmentTimeline object from MPEGDASHParser 55 | """ 56 | segment_timeline = [] 57 | time = 0 58 | media_repr = media.replace("$RepresentationID$", representation) 59 | for segment in timeline.Ss: 60 | segment_duration = segment.d 61 | if segment.t is not None and segment.r is not None: 62 | time = segment.t 63 | media_segment = media_repr.replace("$Time$", str(time)) 64 | r = segment.r + 1 65 | for i in range(0, r): 66 | time, segment_timeline = create_segment( 67 | media_segment, ism_url, 68 | protocol, 69 | time, 70 | segment_duration, 71 | segment_timeline 72 | ) 73 | media_segment = media_repr.replace("$Time$", str(time)) 74 | elif segment.t is None and segment.r is None: 75 | media_segment = media_repr.replace("$Time$", str(time)) 76 | time, segment_timeline = create_segment( 77 | media_segment, ism_url, 78 | protocol, 79 | time, 80 | segment_duration, 81 | segment_timeline 82 | ) 83 | elif segment.t is not None and segment.r is None: 84 | time = segment.t 85 | media_segment = media_repr.replace("$Time$", str(time)) 86 | time, segment_timeline = create_segment( 87 | media_segment, ism_url, 88 | protocol, 89 | time, 90 | segment_duration, 91 | segment_timeline 92 | ) 93 | else: 94 | r = segment.r + 1 95 | for i in range(0, r): 96 | media_segment = media_repr.replace("$Time$", str(time)) 97 | time, segment_timeline = create_segment( 98 | media_segment, ism_url, 99 | protocol, 100 | time, 101 | segment_duration, 102 | segment_timeline 103 | ) 104 | return segment_timeline 105 | 106 | 107 | def prepare_playlist(ism_url, mpd): 108 | """ 109 | mpd: MPEGDASHParser object 110 | Returns 111 | all_reprs: all available representation from MPD manifest 112 | period_s: dict of timelines for each representation and dict of bitrates 113 | available 114 | """ 115 | logger.info("Preparing timeline...") 116 | all_reprs = [] 117 | period_s = {} 118 | dir_mpd = dir(mpd) 119 | if "periods" in dir_mpd: 120 | for period in mpd.periods: 121 | base_urls = (period.base_urls) 122 | protocol = base_urls[0].base_url_value # only dash/ is in index 0 123 | period_s["repr"] = {} 124 | period_s["abr"] = {} 125 | 126 | for adapt_set in period.adaptation_sets: 127 | timeline = [] 128 | content_type = adapt_set.content_type 129 | period_s["abr"][content_type] = {} 130 | period_s["abr"][content_type]["representation"] = [] 131 | period_s["abr"][content_type]["bandwidth"] = [] 132 | media = adapt_set.segment_templates[0].media 133 | timeline = adapt_set.segment_templates[0].segment_timelines[0] 134 | timescale = adapt_set.segment_templates[0].timescale 135 | for repr in adapt_set.representations: 136 | all_reprs.append(repr) 137 | respresentation = repr.id 138 | bandwidth = repr.bandwidth 139 | content_type = adapt_set.content_type 140 | segments_urls = create_segments_timeline( 141 | ism_url, protocol, media, respresentation, timeline 142 | ) 143 | number_segments = len(segments_urls) 144 | period_s["repr"][respresentation] = {} 145 | period_s["repr"][respresentation]["timeline"] = segments_urls 146 | period_s["repr"][respresentation]["bandwidth"] = bandwidth 147 | period_s["repr"][respresentation]["contentType"] = content_type 148 | period_s["repr"][respresentation]["timescale"] = timescale 149 | period_s["repr"][respresentation]["size"] = number_segments 150 | period_s["abr"][content_type]["representation"].append(respresentation) 151 | period_s["abr"][content_type]["bandwidth"].append(bandwidth) 152 | 153 | return all_reprs, period_s 154 | else: 155 | return 0, 0 156 | 157 | 158 | def get_segment_duration(period_segments, chosen_quality, index): 159 | duration = period_segments["repr"][chosen_quality]["timeline"][index]["duration"] 160 | timescale = period_segments["repr"][chosen_quality]["timescale"] 161 | segment_duration = duration/timescale 162 | return segment_duration 163 | 164 | 165 | def simple_playback(self, period_segments, chosen_video, chosen_audio, delay): 166 | number_video_segments = period_segments["repr"][chosen_video]["size"] 167 | for i in range(0, number_video_segments - 1): 168 | video_segment = period_segments["repr"][chosen_video]["timeline"][i]["url"] 169 | self.client.get(video_segment, name="merged") 170 | if LOGGER_SEGMENTS: 171 | print(video_segment) 172 | segment_duration = get_segment_duration( 173 | period_segments, chosen_video, i 174 | ) 175 | if delay: 176 | logger.info(f"Sleeping client for: {segment_duration} seconds") 177 | self._sleep(segment_duration) 178 | else: 179 | pass 180 | for j in range(0, 2): 181 | audio_segment = period_segments["repr"][chosen_audio]["timeline"][i + j]["url"] 182 | self.client.get(audio_segment, name="merged") 183 | if LOGGER_SEGMENTS: 184 | print(audio_segment) 185 | 186 | segment_duration = get_segment_duration( 187 | period_segments, chosen_video, i 188 | ) 189 | logger.info("******* Finished playing the whole timeline *******") 190 | 191 | 192 | def simple_live_playback(self, period_segments, chosen_video, chosen_audio, delay): 193 | number_video_segments = period_segments["repr"][chosen_video]["size"] 194 | for i in range(0, int(number_video_segments/2) - 1): 195 | video_segment = period_segments["repr"][chosen_video]["timeline"][i]["url"] 196 | response = self.client.get(video_segment) 197 | period_segments["repr"][chosen_video]["timeline"].pop(i) 198 | if LOGGER_SEGMENTS: 199 | print(video_segment) 200 | print(response.status_code) 201 | segment_duration = get_segment_duration( 202 | period_segments, chosen_video, i 203 | ) 204 | if delay: 205 | logger.info(f"Sleeping client for: {segment_duration} seconds") 206 | self._sleep(segment_duration) 207 | else: 208 | pass 209 | for j in range(0, 2): 210 | audio_segment = period_segments["repr"][chosen_audio]["timeline"][i + j]["url"] 211 | self.client.get(audio_segment) 212 | if LOGGER_SEGMENTS: 213 | print(audio_segment) 214 | segment_duration = get_segment_duration( 215 | period_segments, chosen_video, i 216 | ) 217 | return period_segments 218 | logger.info("******* Finished playing the whole timeline *******") 219 | 220 | 221 | def simple_buffer(self, segment_count, buffer_size, segment_duration): 222 | min_buffer = buffer_size/2 223 | if segment_count >= min_buffer: 224 | # wait for the last segment duration 225 | logger.info(f"Buffering for {segment_duration} seconds ") 226 | time.sleep(segment_duration) 227 | self._sleep(segment_duration) 228 | segment_count = 0 229 | return segment_count 230 | 231 | 232 | def get_channel_rate(http_response): 233 | """ 234 | Calculate channel_rate based on HTTP request 235 | http_response: requests.response object 236 | return: 237 | chnnel_rate [kbps] 238 | download_duration of segment: [seconds] 239 | """ 240 | channel_rate = None 241 | download_duration = None 242 | content_length = None 243 | code = http_response.status_code 244 | if code == 200: 245 | content_length = http_response.headers['Content-Length'] 246 | content_length = int(content_length) 247 | elapsed = http_response.elapsed 248 | if elapsed.seconds is not None: 249 | microseconds = elapsed.microseconds 250 | seconds = elapsed.seconds 251 | microseconds = microseconds / 1000000 # microseconds to seconds 252 | download_duration = microseconds + seconds # [seconds] 253 | content_length = content_length * 8 # KB to kilobits 254 | channel_rate = content_length / (download_duration * 1000) # [kbps] 255 | else: 256 | logger.error(f"Error request with code: {code} ") 257 | channel_rate = 0 258 | content_length = None 259 | download_duration = None 260 | 261 | return channel_rate, download_duration 262 | 263 | 264 | def buffer_model( 265 | self, buffer_level, segment_duration, download_duration, max_buffer): 266 | """ 267 | self: locust TaskSequence object 268 | buffer_level at epoch t: float [seconds] 269 | segment_duration: float [seconds] 270 | download_duration of segment t [seconds] 271 | max_buffer: int [seconds] 272 | Returns: 273 | updated buffer_level 274 | """ 275 | logger.info(f"Buffer level: {buffer_level} seconds") 276 | delta_t = buffer_level - download_duration + segment_duration - max_buffer 277 | delta_t = abs(delta_t) 278 | diff = buffer_level - download_duration + segment_duration 279 | # Update buffer level 280 | buffer_level = buffer_level + segment_duration - download_duration 281 | 282 | if (diff < max_buffer): 283 | # Request (t + 1)-th segment 284 | return buffer_level 285 | else: 286 | buffer_level -= delta_t # Creates playback 287 | logger.info(f"Buffering for {delta_t} seconds") 288 | self._sleep(delta_t) 289 | time.sleep(delta_t) # Wait before (t + 1)-th segment is requested 290 | return buffer_level 291 | 292 | 293 | def playback_w_buffer( 294 | self, period_segments, chosen_video, chosen_audio, max_buffer=10): 295 | """ 296 | Apply buffer by max_buffer parameter 297 | """ 298 | if isinstance(max_buffer, int): 299 | if LOGGER_SEGMENTS: 300 | logger.info(f"Buffer size: {max_buffer}") 301 | segment_count = 1 # empty buffer initialized 302 | buffer_level = 0 # buffer starts empty 303 | number_video_segments = period_segments["repr"][chosen_video]["size"] 304 | segments = period_segments["repr"] 305 | 306 | for i in range(0, number_video_segments - 1): 307 | video_segment = segments[chosen_video]["timeline"][i]["url"] 308 | logger.info(video_segment) 309 | response = self.client.get(video_segment, name="merged") 310 | channel_rate, download_duration = get_channel_rate(response) 311 | segment_duration = get_segment_duration( 312 | period_segments, chosen_video, i 313 | ) 314 | if LOGGER_SEGMENTS: 315 | logger.info(f"Video segment duration: {segment_duration} seconds") 316 | buffer_level = buffer_model( 317 | self, 318 | buffer_level, segment_duration, download_duration, max_buffer 319 | ) 320 | segment_count += i 321 | if LOGGER_SEGMENTS: 322 | logger.info(f"Number of segments in buffer: {segment_count}") 323 | for j in range(0, 2): 324 | audio_segment = segments[chosen_audio]["timeline"][i+j]["url"] 325 | logger.info(audio_segment) 326 | self.client.get(audio_segment, name="merged") 327 | segment_duration = get_segment_duration( 328 | period_segments, chosen_audio, i+j 329 | ) 330 | if LOGGER_SEGMENTS: 331 | logger.info( 332 | f"Audio segment duration : {segment_duration} seconds" 333 | ) 334 | 335 | else: 336 | logger.error("Your buffer size needs to be an integer") 337 | return 338 | 339 | 340 | def select_representation(abr, option): 341 | """ 342 | Select AdaptationSet with minimum or maximum bitrate 343 | abr: dictionary with represenation[] and bandwidths[] 344 | option: int 0-> lowest bitrate, 1-> highest bitrate 345 | """ 346 | selected_audio = None 347 | selected_video = None 348 | slected_type = ["audio", "video"] 349 | selected_representation = [] 350 | for type_content, content in abr.items(): 351 | if type_content in slected_type: 352 | 353 | if option == HIGHEST_BITRATE: 354 | bitrate = max(content["bandwidth"]) 355 | elif option == LOWEST_BITRATE: 356 | bitrate = min(content["bandwidth"]) 357 | else: 358 | bitrate = random.choice(content["bandwidth"]) 359 | 360 | index = content["bandwidth"].index(bitrate) 361 | representation = content["representation"][index] 362 | if type_content == "video": 363 | selected_video = representation 364 | selected_representation.append(selected_video) 365 | elif type_content == "audio": 366 | selected_audio = representation 367 | selected_representation.append(selected_audio) 368 | else: 369 | pass 370 | return selected_representation 371 | -------------------------------------------------------------------------------- /load_generator/common/hls_emulation.py: -------------------------------------------------------------------------------- 1 | import os 2 | import m3u8 3 | from locust import TaskSet, task 4 | from load_generator.config import default 5 | import logging 6 | import random 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | MANIFEST_FILE = os.getenv('MANIFEST_FILE') 12 | PLAY_MODE = os.getenv("play_mode") 13 | BUFFER_SIZE = os.getenv("buffer_size") 14 | BUFFER_SIZE = int(BUFFER_SIZE) # Cast os.environ str to int 15 | BITRATE = os.getenv("bitrate") 16 | 17 | 18 | LOGGER_SEGMENTS = True 19 | 20 | 21 | class class_hls_player(TaskSet): 22 | """ 23 | Simple HLS emulation of a player 24 | Receives an M3U8 manifest (/.m3u8) 25 | """ 26 | @task(1) 27 | def hls_player_child(self): 28 | print("HLS child player running ...") 29 | base_url = (f"{self.locust.host}/{MANIFEST_FILE}") 30 | 31 | # get master HLS manifest 32 | master_url = f"{base_url}" # It must be a /.m3u8 Master playlist 33 | if LOGGER_SEGMENTS: 34 | print(master_url) 35 | 36 | if PLAY_MODE == "only_manifest": 37 | master_m3u8 = self.client.get(master_url, name="merged") 38 | m3u8.M3U8(content=master_m3u8.text, base_uri=base_url) 39 | 40 | elif PLAY_MODE == "full_playback": 41 | # Retrieve segments with an specific buffer size 42 | master_m3u8 = self.client.get(master_url, name="merged") 43 | parsed_master_m3u8 = m3u8.M3U8(content=master_m3u8.text, base_uri=base_url) 44 | 45 | variant = self.select_bitrate(parsed_master_m3u8) 46 | 47 | variant_url = "{base_url}/{variant}".format(base_url=base_url, variant=variant.uri) 48 | variant_m3u8 = self.client.get(variant_url, name="merged") 49 | parsed_variant_m3u8 = m3u8.M3U8(content=variant_m3u8.text, base_uri=base_url) 50 | 51 | # get all the segments 52 | for segment in parsed_variant_m3u8.segments: 53 | if LOGGER_SEGMENTS: 54 | print(segment.absolute_uri) 55 | self.client.get(segment.absolute_uri, name="merged") 56 | if BUFFER_SIZE != 0: 57 | self._sleep(BUFFER_SIZE) 58 | else: 59 | # Select random segments 60 | master_m3u8 = self.client.get(master_url, name="merged") 61 | parsed_master_m3u8 = m3u8.M3U8(content=master_m3u8.text, base_uri=base_url) 62 | 63 | variant = self.select_bitrate(parsed_master_m3u8) 64 | 65 | variant_url = "{base_url}/{variant}".format(base_url=base_url, variant=variant.uri) 66 | variant_m3u8 = self.client.get(variant_url, name="merged") 67 | parsed_variant_m3u8 = m3u8.M3U8(content=variant_m3u8.text, base_uri=base_url) 68 | 69 | # get random segments 70 | for segment in parsed_variant_m3u8.segments: 71 | segment = random.choice(parsed_variant_m3u8.segments) 72 | if LOGGER_SEGMENTS: 73 | print(segment.absolute_uri) 74 | self.client.get(segment.absolute_uri, name="merged") 75 | if BUFFER_SIZE != 0 and isinstance(BUFFER_SIZE, int): 76 | self._sleep(BUFFER_SIZE) 77 | 78 | def select_bitrate(self, parsed_master_m3u8): 79 | bandwidth_list = [] 80 | for playlist in parsed_master_m3u8.playlists: 81 | bandwidth = playlist.stream_info.bandwidth 82 | bandwidth_list.append(bandwidth) 83 | 84 | if BITRATE == "highest_bitrate": 85 | max_bandwidth = bandwidth_list.index(max(bandwidth_list)) 86 | variant = parsed_master_m3u8.playlists[max_bandwidth] 87 | elif BITRATE == "lowest_bitrate": 88 | min_bandwidth = bandwidth_list.index(min(bandwidth_list)) 89 | variant = parsed_master_m3u8.playlists[min_bandwidth] 90 | else: 91 | # Select a random bitrate 92 | variant = random.choice(parsed_master_m3u8.playlists) 93 | 94 | return variant 95 | 96 | def simple_buffer(self, segment): 97 | seg_get = self.client.get(segment.absolute_uri, name="merged") 98 | sleep = segment.duration - seg_get.elapsed.total_seconds() 99 | self._sleep(sleep) 100 | -------------------------------------------------------------------------------- /load_generator/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unifiedstreaming/streaming-load-testing/3bef149fff58238956789e9615d1f90ce3cfd1ee/load_generator/config/__init__.py -------------------------------------------------------------------------------- /load_generator/config/default.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | MANIFEST_FILE = None 8 | 9 | if "mode" in os.environ: 10 | if os.environ["mode"] not in ["vod", "live"]: 11 | logger.error("That is an incorrect input variable for 'mode'") 12 | try: 13 | sys.exit(0) 14 | except SystemExit: 15 | os._exit(0) 16 | else: 17 | mode = os.environ.get("mode") 18 | print(f"The selected 'mode' variable is: {mode}") 19 | 20 | else: 21 | print("You should specify the mode: 'vod' or 'live'") 22 | try: 23 | sys.exit(1) 24 | except SystemExit: 25 | os._exit(1) 26 | 27 | if "play_mode" in os.environ: 28 | if os.environ["play_mode"] not in ["only_manifest", "full_playback", "random_segments"]: 29 | print( 30 | "You should specify a correct variable for 'play_mode' ENV" 31 | " variable: 'only_manifest', 'full_playback', 'random_segments'" 32 | ) 33 | try: 34 | sys.exit(0) 35 | except SystemExit: 36 | os._exit(0) 37 | else: 38 | play_mode = os.environ.get("play_mode") 39 | print(f"The selected 'play_mode' variable is: {play_mode}") 40 | 41 | else: 42 | # Default behaviour if play_mode is not set 43 | os.environ["play_mode"] = "full_playback" 44 | print("Default play_mode is set to: 'full_playback'") 45 | 46 | if "bitrate" in os.environ: 47 | if os.environ["bitrate"] not in ["highest_bitrate", "lowest_bitrate", "random_bitrate"]: 48 | print( 49 | "You should specify a correct variable for 'bitrate' ENV" 50 | " variable: highest_birtate, lowest_bitrate, random_bitrate" 51 | ) 52 | try: 53 | sys.exit(0) 54 | except SystemExit: 55 | os._exit(0) 56 | else: 57 | bitrate = os.environ.get("bitrate") 58 | print(f"The selected 'bitrate' variable is: {bitrate}") 59 | 60 | else: 61 | # Default behaviour if bitrate is not set 62 | os.environ["bitrate"] = "highest_bitrate" 63 | print( 64 | "'bitrate' ENV variable is not set. Default 'bitrate' is set to: " 65 | "'highest_bitrate'" 66 | ) 67 | 68 | # Create list of possible buffer_size 69 | list_input = str(list(range(0, 10))) 70 | if "buffer_size" in os.environ: 71 | if os.environ["buffer_size"] not in list_input: 72 | print( 73 | "You should specify a correct variable for 'buffer_size' ENV" 74 | " variable: integers from 0 to 10" 75 | ) 76 | try: 77 | sys.exit(1) 78 | except SystemExit: 79 | os._exit(1) 80 | else: 81 | buffer_size = os.environ.get("buffer_size") 82 | print(f"The selected 'buffer_size' variable is: {buffer_size}") 83 | else: 84 | os.environ["buffer_size"] = "0" 85 | print( 86 | "'buffer_size' ENV variable is not set. Default 'buffer_size'" 87 | "is set to: 0" 88 | ) 89 | 90 | if ("time_shift" in os.environ) and (os.environ.get("mode") == "live"): 91 | if os.environ["time_shift"] not in ["-4", "-3", "-2", "-1", "0", "1"]: 92 | print( 93 | "You should specify a correct variable for 'time_shift' ENV" 94 | " int variable: -4, -3, 2, 1, 0, 1" 95 | ) 96 | try: 97 | sys.exit(1) 98 | except SystemExit: 99 | os._exit(1) 100 | else: 101 | mode = os.environ.get("mode") 102 | print(f"Mode before starting time_shift is : {mode}") 103 | time_shift = os.environ.get("time_shift") 104 | print(f"The selected 'time_shift' variable is: {time_shift} with type: {type(time_shift)}") 105 | 106 | elif ("time_shift" in os.environ) and (os.environ.get("mode") == "vod"): 107 | print( 108 | "'time_shift' ENV can only be used with mode=live" 109 | ) 110 | try: 111 | sys.exit(1) 112 | except SystemExit: 113 | os._exit(1) 114 | else: 115 | print("'time_shift' ENV variable is not set") 116 | 117 | 118 | if "MANIFEST_FILE" not in os.environ: 119 | print("You are required to set MANIFEST_FILE ENV variable ") 120 | try: 121 | sys.exit(1) 122 | except SystemExit: 123 | os._exit(1) 124 | else: 125 | MANIFEST_FILE = os.getenv('MANIFEST_FILE') 126 | print(f"**** The manifest file is: {MANIFEST_FILE} ****") 127 | -------------------------------------------------------------------------------- /load_generator/locustfiles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unifiedstreaming/streaming-load-testing/3bef149fff58238956789e9615d1f90ce3cfd1ee/load_generator/locustfiles/__init__.py -------------------------------------------------------------------------------- /load_generator/locustfiles/dash_sequence.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from locust import HttpLocust, between, seq_task, TaskSequence 4 | from mpegdash.parser import MPEGDASHParser 5 | from load_generator.common import dash_utils 6 | from load_generator.config import default # ENV configuration 7 | import logging 8 | 9 | if sys.version_info[0] < 3: 10 | raise Exception("Must be using Python 3") 11 | 12 | logger = logging.getLogger(__name__) 13 | print(resource.getrlimit(resource.RLIMIT_NOFILE)) 14 | # set the highest limit of open files in the server 15 | resource.setrlimit(resource.RLIMIT_NOFILE, resource.getrlimit( 16 | resource.RLIMIT_NOFILE) 17 | ) 18 | 19 | MANIFEST_FILE = os.getenv('MANIFEST_FILE') 20 | 21 | 22 | class UserBehaviour(TaskSequence): 23 | """ 24 | Example task sequences with global values 25 | """ 26 | base_url = None 27 | mpd_body = None 28 | mpd_object = None 29 | 30 | @seq_task(1) 31 | def get_manifest(self): 32 | """ 33 | Retrieve the MPD manifest file 34 | """ 35 | # mode = os.environ.get("mode") 36 | base_url = f"{self.locust.host}/{MANIFEST_FILE}" 37 | self.base_url = base_url 38 | logger.info(f"Requesting manifest: {base_url}/.mpd") 39 | response_mpd = self.client.get(f"{base_url}/.mpd") 40 | self.mpd_body = response_mpd.text 41 | # Exit the program if the Manifest file is not reachable 42 | if response_mpd.status_code == 0: 43 | logger.error( 44 | f"Make sure your Manifest URI is reachable: {base_url}" 45 | ) 46 | try: 47 | sys.exit(1) 48 | except SystemExit: 49 | os._exit(1) 50 | 51 | @seq_task(2) 52 | def dash_parse(self): 53 | """ 54 | Parse Manifest file to MPEGDASHParser 55 | """ 56 | self.mpd_object = MPEGDASHParser.parse(self.mpd_body) 57 | 58 | @seq_task(3) 59 | def dash_playback(self): 60 | """ 61 | Create a list of the avaialble segment URIs with 62 | its specific media representation 63 | """ 64 | all_reprs, period_segments = dash_utils.prepare_playlist( 65 | self.base_url, self.mpd_object 66 | ) 67 | bitrate = os.environ.get("bitrate") 68 | selected_representation = dash_utils.select_representation( 69 | period_segments["abr"], 70 | bitrate # highest_bitrate, lowest_bitrate, random_bitrate 71 | ) 72 | buffer_size = int(os.environ.get("buffer_size")) 73 | if buffer_size == 0: 74 | dash_utils.simple_playback( 75 | self, 76 | period_segments, 77 | selected_representation[1], 78 | selected_representation[0], 79 | False 80 | ) 81 | else: 82 | dash_utils.playback_w_buffer( 83 | self, 84 | period_segments, 85 | selected_representation[1], 86 | selected_representation[0], 87 | buffer_size 88 | ) 89 | 90 | 91 | class MyLocust(HttpLocust): 92 | host = os.getenv('HOST_URL', "http://localhost") 93 | task_set = UserBehaviour 94 | wait_time = between(0, 0) 95 | -------------------------------------------------------------------------------- /load_generator/locustfiles/hls_player.py: -------------------------------------------------------------------------------- 1 | ################################################## 2 | # Simple emulator of an HLS media player 3 | ################################################## 4 | # MIT License 5 | ################################################## 6 | # Author: Mark Ogle 7 | # License: MIT 8 | # Email: mark@unified-streaming.com 9 | # Maintainer: roberto@unified-streaming.com 10 | ################################################## 11 | 12 | import os 13 | from locust import HttpLocust, TaskSet, task, between 14 | import m3u8 15 | import logging 16 | import resource 17 | import sys 18 | import random 19 | 20 | if sys.version_info[0] < 3: 21 | raise Exception("Must be using Python 3") 22 | 23 | logger = logging.getLogger(__name__) 24 | print(resource.getrlimit(resource.RLIMIT_NOFILE)) 25 | # set the highest limit of open files in the server 26 | resource.setrlimit(resource.RLIMIT_NOFILE, resource.getrlimit( 27 | resource.RLIMIT_NOFILE) 28 | ) 29 | 30 | MANIFEST_FILE = os.getenv('MANIFEST_FILE') 31 | 32 | 33 | class PlayerTaskSet(TaskSet): 34 | @task(1) 35 | def play_stream(self): 36 | """ 37 | Play complete stream. 38 | Steps: 39 | * get manifest 40 | * select highest bitrate 41 | * get each segment in order 42 | * wait for segment duration in between downloads, to act somewhat like 43 | a player kinda dumb hack to make results gathering easier is to merge 44 | everything into a single name 45 | """ 46 | 47 | base_url = (f"{self.locust.host}/{MANIFEST_FILE}") 48 | 49 | # get manifest 50 | # single content 51 | master_url = f"{base_url}/.m3u8" 52 | master_m3u8 = self.client.get(master_url, name="merged") 53 | parsed_master_m3u8 = m3u8.M3U8(content=master_m3u8.text, base_uri=base_url) 54 | 55 | random_variant = random.choice(parsed_master_m3u8.playlists) 56 | variant_url = "{base_url}/{variant}".format(base_url=base_url, variant=random_variant.uri) 57 | variant_m3u8 = self.client.get(variant_url, name="merged") 58 | parsed_variant_m3u8 = m3u8.M3U8(content=variant_m3u8.text, base_uri=base_url) 59 | 60 | # get all the segments 61 | for segment in parsed_variant_m3u8.segments: 62 | logger.debug("Getting segment {0}".format(segment.absolute_uri)) 63 | seg_get = self.client.get(segment.absolute_uri) 64 | sleep = segment.duration - seg_get.elapsed.total_seconds() 65 | logger.debug("Request took {elapsed} and segment duration is {duration}. Sleeping for {sleep}".format( 66 | elapsed=seg_get.elapsed.total_seconds(), duration=segment.duration, sleep=sleep)) 67 | self._sleep(sleep) 68 | 69 | 70 | class MyLocust(HttpLocust): 71 | host = os.getenv('HOST_URL', "http://localhost") 72 | task_set = PlayerTaskSet 73 | wait_time = between(0, 0) 74 | -------------------------------------------------------------------------------- /load_generator/locustfiles/vod_dash_hls_sequence.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from locust import HttpLocust, between, TaskSequence 4 | # seq_task 5 | # from load_generator.common.dash_emulation import class_dash_player 6 | from load_generator.common.dash_emulation import class_dash_player 7 | from load_generator.common.hls_emulation import class_hls_player 8 | import logging 9 | import resource 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | if sys.version_info[0] < 3: 14 | raise Exception("You must usea version above Python 3") 15 | 16 | logger = logging.getLogger(__name__) 17 | print(resource.getrlimit(resource.RLIMIT_NOFILE)) 18 | # set the highest limit of open files in the server 19 | resource.setrlimit(resource.RLIMIT_NOFILE, resource.getrlimit(resource.RLIMIT_NOFILE)) 20 | 21 | MANIFEST_FILE = os.getenv('MANIFEST_FILE') 22 | 23 | 24 | class Client(TaskSequence): 25 | """ 26 | Verifies if it is a MPEG-DASH or HLS manifest 27 | """ 28 | def on_start(self): 29 | base_url = f"{self.locust.host}/{MANIFEST_FILE}" 30 | self.base_url = base_url 31 | if base_url.endswith(".mpd"): 32 | logger.info("It is a MPEG-DASH URI") 33 | self.schedule_task(class_dash_player) # --> load_generator.common.dash_emulation 34 | elif base_url.endswith(".m3u8"): 35 | logger.info("It is a HLS URI") 36 | self.schedule_task(class_hls_player) # -> load_generator.common.hls_emulation 37 | else: 38 | logger.error( 39 | "The URI provided is not supported for MPEG-DASH or " 40 | "HLS media endpoint. Make sure the MANIFEST_FILE " 41 | "envrionment ends with '.mpd' or '.m3u8'" 42 | ) 43 | try: 44 | sys.exit(0) 45 | except SystemExit: 46 | os._exit(0) 47 | 48 | # Check if the Manifest is available before proceeding to test 49 | manifest_response = self.client.get(self.base_url, name="merged") 50 | if manifest_response.status_code != 200: 51 | logger.error( 52 | f"The Manifest endpoint is not reachable. Verify that " 53 | f" you can reach the Manifest file: {self.base_url}" 54 | ) 55 | try: 56 | sys.exit(1) 57 | except SystemExit: 58 | os._exit(1) 59 | 60 | 61 | class MyLocust(HttpLocust): 62 | host = os.getenv('HOST_URL', "http://localhost") 63 | task_set = Client 64 | wait_time = between(0, 0) 65 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -p no:warnings 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | coverage 3 | locustio==0.14.4 4 | mpegdash==0.2.0 5 | m3u8==0.3.6 6 | requests==2.23.0 7 | 8 | -e . 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from setuptools import setup, find_packages 4 | 5 | 6 | with open('README.rst') as f: 7 | readme = f.read() 8 | 9 | setup( 10 | name='streamin-load-testing', 11 | version='0.1.0', 12 | description='Load testing for video streaming setups', 13 | long_description=readme, 14 | author='R. Ramos-Chavez', 15 | packages=find_packages(exclude=('tests', 'docs')) 16 | ) 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unifiedstreaming/streaming-load-testing/3bef149fff58238956789e9615d1f90ce3cfd1ee/tests/__init__.py -------------------------------------------------------------------------------- /tests/manifest_samples/manifest.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 15 | dash/ 16 | 28 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 226 | 227 | 230 | 231 | 232 | 240 | 241 | 245 | 246 | 247 | 248 | 249 | 250 | 253 | 254 | 255 | 263 | 264 | 268 | 269 | 270 | 271 | 272 | 273 | 276 | 277 | 278 | 292 | 293 | 297 | 298 | 299 | 300 | 301 | 302 | 309 | 310 | 317 | 318 | 325 | 326 | 333 | 334 | 335 | 336 | 337 | -------------------------------------------------------------------------------- /tests/test_vod.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | os.environ["GEVENT_SUPPORT"] = "True" 4 | os.environ["PYDEVD_USE_FRAME_EVAL"] = "NO" # Set to remove GEVENT_SUPPORT support warning 5 | # *** Import requirements for gevent warnings: https://github.com/gevent/gevent/issues/1016#issuecomment-328529454 6 | import gevent.monkey 7 | gevent.monkey.patch_all() 8 | from requests.packages.urllib3.util.ssl_ import create_urllib3_context 9 | create_urllib3_context() 10 | # *** Ends import requirements for gevent 11 | import requests 12 | import subprocess 13 | import json 14 | from mpegdash.parser import MPEGDASHParser 15 | from load_generator.common import dash_utils 16 | from locust import HttpLocust, Locust, TaskSet, between 17 | print(f"__file__{__file__}") 18 | from locust.exception import InterruptTaskSet 19 | os.environ["mode"] = "vod" 20 | os.environ["MANIFEST_FILE"] = "video/ateam/ateam.ism/.m3u8" 21 | from load_generator.common.dash_emulation import class_dash_player 22 | from load_generator.common.hls_emulation import class_hls_player 23 | 24 | 25 | @pytest.mark.parametrize("hostname,ism_path", [( 26 | 'http://demo.unified-streaming.com', 27 | 'video/ateam/ateam.ism' 28 | ) 29 | ]) 30 | @pytest.mark.parametrize("streaming_output", ["m3u8", "mpd"]) 31 | @pytest.mark.parametrize("play_mode", [ 32 | "only_manifest", "full_playback", "random_segments" 33 | ]) 34 | @pytest.mark.parametrize("bitrate", [ 35 | "highest_bitrate", "lowest_bitrate", "random_bitrate" 36 | ]) 37 | @pytest.mark.parametrize("buffer_size", ["0"]) # ["0", "1", "2"]) 38 | def test_vod_dash_hls_sequence( 39 | hostname, ism_path, streaming_output, play_mode, bitrate, buffer_size): 40 | current_directory = os.getcwd() 41 | print(f"Current directory: {current_directory}") 42 | locust_str_cmd = ( 43 | f"locust " 44 | f"-f {current_directory}/load_generator/locustfiles/vod_dash_hls_sequence.py" 45 | f" --no-web" 46 | f" -c 1" 47 | f" -r 1" 48 | f" --run-time 1s" 49 | f" --only-summary") 50 | locust_list_cmd = locust_str_cmd.split(' ') 51 | print(f"Locust command:\n {locust_list_cmd}") 52 | my_env = os.environ.copy() 53 | my_env["HOST_URL"] = hostname 54 | my_env["MANIFEST_FILE"] = f"{ism_path}/.{streaming_output}" 55 | my_env["mode"] = "vod" 56 | my_env["play_mode"] = play_mode 57 | my_env["bitrate"] = bitrate 58 | my_env["buffer_size"] = buffer_size # os.environ requires a str 59 | 60 | print( 61 | f"my_env: HOST_URL={my_env['HOST_URL']}, " 62 | f"mode={my_env['mode']}, " 63 | f"MANIFEST_FILE={my_env['MANIFEST_FILE']}") 64 | process = subprocess.Popen( 65 | locust_list_cmd, 66 | env=my_env, 67 | stdout=subprocess.PIPE, 68 | stderr=subprocess.PIPE, 69 | ) 70 | stdout, stderr = process.communicate() 71 | print(f"stdout={stdout}") 72 | exit_code = process.returncode 73 | print(f"Return code: {exit_code}") 74 | json_error = stderr.decode('utf8').replace("'", '"') 75 | print(json_error) 76 | assert 0 == exit_code 77 | --------------------------------------------------------------------------------