pax_global_header00006660000000000000000000000064146754702650014532gustar00rootroot0000000000000052 comment=d5eb3e79e55feadbdec24a2d00fe426536c5d491 aiortsp-1.4.0/000077500000000000000000000000001467547026500132155ustar00rootroot00000000000000aiortsp-1.4.0/.gitignore000066400000000000000000000023051467547026500152050ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # PyCharm .idea/ aiortsp-1.4.0/.travis.yml000066400000000000000000000005071467547026500153300ustar00rootroot00000000000000sudo: false language: python python: - "3.6" - "3.7" - "3.8" cache: pip before_install: - pip install -U pip setuptools - pip install -r requirements-dev.txt - pip install tox-travis - pip install . script: - tox - coverage run --source aiortsp -m py.test - coverage report -m after_success: - coverallsaiortsp-1.4.0/CHANGELOG.rst000066400000000000000000000031331467547026500152360ustar00rootroot00000000000000================ Versions history ================ This repository follows changelog_. We try to stick to **Semantic versioning**. [1.4.0] - 2024-09-27 ==================== Added ----- * Basic support for RTSPS (but no claim on RTSP/2.0) - Thanks Joshua Wise [1.3.7] - 2023-09-01 ==================== Fixed ----- * fix RTSPReader to prevent sending URL credentials into the logs [1.3.6] - 2021-06-30 ==================== Fixed ----- * fix an issue with RTCP statistics not properly detecting reordering * fix a issue with RTCP possibly overflowing where there are too many lost packets [1.3.5] - 2021-03-02 ==================== Fixed ----- * Adapt to servers not supporting GET_PARAMETERS and OPTIONS [1.3.4] - 2021-02-18 ==================== Fixed ----- * Start CSeq to 1 instead of 0 for some peaky servers Misc ---- * Enforce py36 or higher, as it does not work below that point. * Fix license specifier which was incorrect [1.3.3] - 2020-06-05 ==================== Fixed ----- * Fix an issue when RTSP response headers are fragmented across multiple TCP reads [1.3.2] - 2020-02-12 ==================== Fixed ----- * Some servers don't like when CSeq is not the first header... [1.3.1] - 2019-12-11 ==================== Fixed ----- * Check transport status in session keep alive loops [1.3.0] - 2019-10-10 ==================== Added ----- * Add a simplified ``RTSPReader`` class for easy RTP gathering [1.2.1] - 2019-10-05 ==================== Added ----- * First Open Source version .. ### PUT ANY REFERENCE TO HERE .. _changelog: https://keepachangelog.com/en/1.0.0/ aiortsp-1.4.0/LICENSE000066400000000000000000000167441467547026500142360ustar00rootroot00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. aiortsp-1.4.0/README.rst000066400000000000000000000025021467547026500147030ustar00rootroot00000000000000RTSP Library for asyncio ======================== .. image:: https://travis-ci.com/marss/aiortsp.svg?branch=master :target: https://travis-ci.com/marss/aiortsp .. image:: https://coveralls.io/repos/github/marss/aiortsp/badge.svg?branch=master :target: https://coveralls.io/github/marss/aiortsp?branch=master This is a very simple asyncio library for interacting with an RTSP server, with basic RTP/RTCP support. The intended use case is to provide a pretty low level control of what happens at RTSP connection level, all in python/asyncio. This library does not provide any decoding capability, it is up to the client to decide what to do with received RTP packets. One could easily decode using `OpenCV `_ or `PyAV `_, or not at all depending on the intended use. See ``examples`` for how to use the lib internals, butfor quick usage: .. code-block:: python3 import asyncio from aiortsp.rtsp.reader import RTSPReader async def main(): # Open a reader (which means RTSP connection, then media session) async with RTSPReader('rtsp://cam/video.sdp') as reader: # Iterate on RTP packets async for pkt in reader.iter_packets(): print('PKT', pkt.seq, pkt.pt, len(pkt)) asyncio.run(main()) aiortsp-1.4.0/aiortsp/000077500000000000000000000000001467547026500146765ustar00rootroot00000000000000aiortsp-1.4.0/aiortsp/__init__.py000066400000000000000000000001001467547026500167760ustar00rootroot00000000000000""" Asyncio RTSP Library See README.rst for usage examples """ aiortsp-1.4.0/aiortsp/__version__.py000066400000000000000000000000571467547026500175330ustar00rootroot00000000000000""" aiortsp version """ __version__ = "1.4.0" aiortsp-1.4.0/aiortsp/rtcp/000077500000000000000000000000001467547026500156465ustar00rootroot00000000000000aiortsp-1.4.0/aiortsp/rtcp/__init__.py000066400000000000000000000000661467547026500177610ustar00rootroot00000000000000""" RTP Management module. ---------------------- """ aiortsp-1.4.0/aiortsp/rtcp/parser.py000066400000000000000000000241501467547026500175160ustar00rootroot00000000000000""" RTCP Parsing module ------------------- This should really just be a lib call like we do for RTP (using dpkt), but there is no light/easily importable RTCP parser available... """ from abc import ABC, abstractmethod from struct import unpack, pack from typing import Tuple, Dict, Type, Optional, List TS_OFFSET_1900 = 2208988800 def ts_to_ntp(ts: float) -> Tuple[int, int]: """ Convert from time.time() output to NTP (seconds, fraction). """ ts += TS_OFFSET_1900 # From 1900 to 1970 seconds, fraction = divmod(ts, 1) return int(seconds), int(fraction * 2**32) def ntp_to_ts(seconds: int, fraction: int) -> float: """ Convert from NTP (seconds, fraction) to time similar to time.time() output. """ return (seconds + fraction / 2**32) - TS_OFFSET_1900 RTCP_TYPES: Dict[int, Type['RTCPPacket']] = {} class RTCPPacket: """ Sub-content of a generic RTCP container """ pt = 0 def __init_subclass__(cls, **kwargs): """ Register RTCP type in the RTCP_TYPES registry """ super().__init_subclass__(**kwargs) if cls.pt: RTCP_TYPES[cls.pt] = cls @classmethod @abstractmethod def unpack(cls, px, pt, p_len, data) -> 'RTCPPacket': """ Unpack an RTCP sub-packet """ @abstractmethod def __bytes__(self): """Serialize to bytes""" def pack(self, count, value, pad=False) -> bytes: """ Turn an RTCP packet back into bytes """ length = len(value) // 4 + (1 if len(value) % 4 != 0 else 0) px = 0x80 | (pad and len(value) % 4 != 0 and 0x20 or 0x00) | (count & 0x1f) header = pack('!BBH', px, self.pt, length) if not pad or len(value) % 4 == 0: padding = b'' else: padding = b'\x00' * (4 - len(value) % 4 - 1) + pack('!B', 4 - len(value) % 4) return header + value + padding class SRReport: """ Sender/Receiver report content """ def __init__(self, ssrc, flost, clost, hseq, jitter, lsr, dlsr): self.ssrc = ssrc self.flost = flost self.clost = clost self.hseq = hseq self.jitter = jitter self.lsr = lsr self.dlsr = dlsr @classmethod def unpack(cls, data: bytes) -> 'SRReport': """ Transform a report into bytes """ assert len(data) == 24 ssrc, lost, hseq, jitter, lsr, dlsr = unpack('!IIIIII', data) flost, clost = (lost >> 24) & 0xFF, (lost & 0x00FFFFFF) return cls(ssrc, flost, clost, hseq, jitter, lsr, dlsr) def __repr__(self): return f'' def __bytes__(self): return pack( '!IIIIII', self.ssrc, (self.flost << 24) | (self.clost & 0x00FFFFFF), self.hseq & 0xFFFFFFFF, self.jitter, self.lsr, self.dlsr ) class SRRR(RTCPPacket, ABC): """ Base SR/RR class (most things in common) """ def __init__(self, ssrc, extn=None, reports=None): self.ssrc = ssrc self.extn = extn self.reports = reports or [] @classmethod def unpack(cls, px, pt, p_len, data): raise NotImplementedError @classmethod def parse_reports(cls, px, data): """ Parse internal reports """ reports = [] for _ in range(px & 0x1F): assert len(data) >= 24 report_data = data[:24] reports.append(SRReport.unpack(report_data)) data = data[24:] return reports class SR(SRRR): """ SR sub-type """ pt = 200 def __init__(self, ssrc, ntp, ts=0, pkt_count=0, byte_count=0, extn=None, reports=None): super().__init__(ssrc=ssrc, extn=extn, reports=reports) self.ntp = ntp self.ts = ts self.pkt_count = pkt_count self.byte_count = byte_count @classmethod def unpack(cls, px, pt, p_len, data): ssrc, ntp1, ntp2, ts, pkt_count, byte_count = unpack('!IIIIII', data[:24]) ntp = ntp_to_ts(ntp1, ntp2) reports = cls.parse_reports(px, data[24:]) return cls(ssrc=ssrc, ntp=ntp, ts=ts, pkt_count=pkt_count, byte_count=byte_count, reports=reports) def __repr__(self): return f'' def __bytes__(self): ntp1, ntp2 = ts_to_ntp(self.ntp) value = pack('!IIIIII', self.ssrc, ntp1, ntp2, self.ts, self.pkt_count, self.byte_count) count = len(self.reports) for report in self.reports: value += bytes(report) if self.extn: value += self.extn return self.pack(count, value) class RR(SRRR): """ RR sub-type """ pt = 201 @classmethod def unpack(cls, px, pt, p_len, data): ssrc, = unpack('!I', data[:4]) reports = cls.parse_reports(px, data[4:]) return cls(ssrc, reports=reports) def __repr__(self): return f'' def __bytes__(self): value = pack('!I', self.ssrc) count = len(self.reports) for report in self.reports: value += bytes(report) if self.extn: value += self.extn return self.pack(count, value) class SDES(RTCPPacket): """ SDES sub-type """ pt = 202 CNAME, NAME, EMAIL, PHONE, LOC, TOOL, NOTE, PRIV = range(1, 9) def __init__(self, items=None): self.items = items or [] @classmethod def unpack(cls, px, pt, p_len, data): items = [] for _ in range(0, px & 0x1F): ssrc, = unpack('!I', data[:4]) local_items = [] data, count = data[4:], 0 while len(data) >= 2: itype, ilen = unpack('!BB', data[:2]) count += (2 + ilen) ivalue, data = data[2:2+ilen], data[2+ilen:] if itype == 0: break local_items.append((itype, ivalue)) if count % 4 != 0: data = data[(4-count % 4):] items.append((ssrc, local_items)) return cls(items=items) def __repr__(self): return f'' def __bytes__(self): value = b'' count = len(self.items) for ssrc, items in self.items: chunk = pack('!I', ssrc) for n, v in items: chunk += pack('!BB', n, len(v) > 255 and 255 or len(v)) + v[:256] chunk += pack('!BB', 0, 0) # to indicate end of items. if len(chunk) % 4 != 0: chunk += b'\x00' * (4 - len(chunk) % 4) value += chunk return self.pack(count, value) class BYE(RTCPPacket): """ BYE sub-type """ pt = 203 def __init__(self, ssrcs=None, reason=None): self.ssrcs = ssrcs or [] self.reason = reason @classmethod def unpack(cls, px, pt, p_len, data): ssrcs, reason = [], None for _ in range(0, px & 0x01F): ssrc, = unpack('!I', data[:4]) ssrcs.append(ssrc) data = data[4:] if data: rlen, = unpack('!B', data[:1]) reason = data[1:1+rlen] return cls(ssrcs=ssrcs, reason=reason) def __repr__(self): return f'' def __bytes__(self): value = b'' count = len(self.ssrcs) for ssrc in self.ssrcs: value += pack('!I', ssrc) if self.reason: reason_len = min(len(self.reason), 255) value += pack('!B', reason_len) + self.reason[:reason_len] return self.pack(count, value) class APP(RTCPPacket): """ APP sub-type """ pt = 204 def __init__(self, subtype, ssrc, name, data=None): self.subtype = subtype self.ssrc = ssrc self.name = name self.data = data @classmethod def unpack(cls, px, pt, p_len, data): subtype = px & 0x1F ssrc, name = unpack('!I4s', data[:8]) data = data[8:] return cls(subtype=subtype, ssrc=ssrc, name=name, data=data) def __repr__(self): return f'' def __bytes__(self): return self.pack(self.subtype, self.data) class RTCP: """ RTP Control Protocol Packet """ def __init__(self, packets=None): self.packets: List[RTCPPacket] = packets or [] @classmethod def unpack(cls, data: bytes) -> 'RTCP': """ Parse a buffer into an RTCP instance """ packets = [] # Iterate on every packet found while data: # Unpack the header before deciding type px, pt, p_len = unpack('!BBH', data[:4]) # Check version if px & 0xC0 != 0x80: raise ValueError('RTP version must be 2') # Check type exists if pt not in RTCP_TYPES: raise ValueError(f'Not an RTCP packet type: {pt}') # Split current bytes and bytes from next packet payload_length = 4 + p_len * 4 if payload_length > len(data): raise ValueError(f'RTCP Packet truncated ({payload_length} > {len(data)})') payload, data = data[4:payload_length], data[payload_length:] if px & 0x20: # There is some padding to be removed payload = payload[:len(payload)-payload[-1]] packets.append(RTCP_TYPES[pt].unpack(px, pt, p_len, payload)) return cls(packets=packets) def get(self, pt: int) -> Optional[RTCPPacket]: """ Return first occurrence of given payload type, if any. """ match = [p for p in self.packets if p.pt == pt] return None if not match else match[0] def __repr__(self): return f'RTCP({self.packets})' def __bytes__(self): result = b'' for packet in self.packets: result += bytes(packet) return result aiortsp-1.4.0/aiortsp/rtcp/stats.py000066400000000000000000000152531467547026500173640ustar00rootroot00000000000000""" RTCP Statistics module """ import logging import random from math import fabs from time import time from typing import Optional from dpkt.rtp import RTP from .parser import RTCP, SRReport, ts_to_ntp, RR, SDES, BYE, SR _logger = logging.getLogger('rtcp.sink') RTP_SEQ_MOD = (1 << 16) MAX_MISORDER = 100 MAX_DROPOUT = 3000 UINT32_MASK = 0xFFFFFFFF UINT32_MASK = 0xFFFFFFFF NTP_MASK = 0xFFFF class RTCPStats: """ RTCP Statistics and response builder ------------------------------------ This class builds statistics upon reception of RTP frames, handles reception of remote RTCP report from the server, and crafts RTCP report packet. This is a simplified version not handling multicast yet. """ def __init__(self, name='unknown'): self.name = name self.ssrc = random.randint(0, 2**32) self.ntp = self.ntp_base = time() self.received = self.expected_prior = self.received_prior = self.transit = self.jitter = 0 self.lost = self.fraction = self.pkt_count = self.oct_count = self.timeout = 0 self.probation = self.last_received = self.ts = self.ts_base = 0 self.maxseq = self.bad_seq = self.base_seq = self.cycles = None self.last_ts = self.last_ntp = self.rtcp_delay = None @property def cname(self) -> bytes: """ Return CNAME for RR reporting """ return f'{self.ssrc}@{self.name}'.encode() @property def extended_seq(self) -> int: """Extended sequence number, taking cycles into account""" return self.cycles + self.maxseq @property def lsr(self) -> int: """Build LSR report value""" ntp1, ntp2 = ts_to_ntp(self.last_ntp) return ((ntp1 & NTP_MASK) << 16) | ((ntp2 >> 16) & NTP_MASK) @property def dlsr(self) -> int: """Build DLSR report value""" return max(0, min(UINT32_MASK, int((time() - self.last_ntp) * 65536))) def init_seq(self, seq): """ Initialize the seq using the newly received seq of RTP packet. """ self.base_seq = self.maxseq = seq self.bad_seq = seq - 1 self.cycles = self.received = self.received_prior = self.expected_prior = 0 def update_seq(self, seq): """ Update the source properties based on received RTP packet's seq. """ seq_delta = (seq - self.maxseq) % 65536 if self.probation > 0: if seq == self.maxseq + 1: self.probation = self.probation - 1 self.maxseq = seq if self.probation == 0: # reset self.init_seq(seq) self.received = self.received + 1 return else: # next packet should be in sequence self.probation = 1 self.maxseq = seq return elif seq_delta < MAX_DROPOUT: # in order, within reasonable gap if seq < self.maxseq: self.cycles += RTP_SEQ_MOD self.maxseq = seq elif seq_delta <= (RTP_SEQ_MOD - MAX_MISORDER): # Huge gap if seq == self.bad_seq: # Sequence was either reset or changed self.init_seq(seq) else: self.bad_seq = (seq + 1) & (RTP_SEQ_MOD - 1) return # else: # duplicate or reordered packet # Count this packet self.received += 1 def update_jitter(self, ts): """ Update the jitter based on ts and arrival (in ts units). """ transit = int(self.ts_now - ts) d, self.transit = int(fabs(transit - self.transit)), transit self.jitter += (1 / 16) * (d - self.jitter) def update_lost_expected(self): """ Update the number of packets expected and lost for reporting. """ expected = self.extended_seq - self.base_seq + 1 received = self.received received_interval = received - self.received_prior expected_interval = expected - self.expected_prior lost_interval = expected_interval - received_interval self.expected_prior = expected self.received_prior = received self.lost = expected - received if expected_interval == 0 or lost_interval <= 0: self.fraction = 0 else: self.fraction = (lost_interval << 8) // expected_interval @staticmethod def rtcp_interval(initial=False) -> float: """ Simplified RTCP interval calculator. TODO implement multicast. In unicast, it does not really matter... :param initial: Is this the first interval (shorter) """ return (2.5 if initial else 5.0) * (random.random() + 0.5) / 1.21828 @property def ts_now(self): """The current RTP timestamp in ts unit based on current time.""" ts = self.ts if self.ntp != self.ntp_base: ts += (time() - self.ntp) * ((self.ts - self.ts_base) / (self.ntp - self.ntp_base)) return int(ts) & UINT32_MASK def update(self, pkt: RTP): """ Called externally to handle a received RTP packet and update statistics. """ seq = pkt.seq if self.base_seq is None: self.init_seq(seq) self.update_seq(seq) self.update_jitter(pkt.ts) self.last_received = time() def handle_rtcp(self, rtcp: RTCP): """ RTCP packet received """ for p in rtcp.packets: if isinstance(p, SR): self.last_ts = p.ts self.last_ntp = p.ntp def build_rtcp(self, send_bye=False) -> Optional[RTCP]: """ Build an RTCP packet based on current situation. There may be not enough info yet to return anything! :param send_bye: Add a bye header """ if self.last_ntp is None: # Not received a first SR report yet... return None if self.base_seq is None: # Not received an RTP packet yet... return None self.update_lost_expected() rtcp = RTCP() # Add receiver report rr = RR(self.ssrc, reports=[ SRReport( ssrc=self.ssrc, flost=self.fraction, clost=self.lost, hseq=self.extended_seq, jitter=int(self.jitter), lsr=self.lsr, dlsr=self.dlsr) ]) rtcp.packets.append(rr) # Add SDES identity sdes = SDES(items=[(1, [(SDES.CNAME, self.cname)])]) rtcp.packets.append(sdes) # Add bye if requested if send_bye: bye = BYE(ssrcs=[self.ssrc]) rtcp.packets.append(bye) return rtcp aiortsp-1.4.0/aiortsp/rtsp/000077500000000000000000000000001467547026500156665ustar00rootroot00000000000000aiortsp-1.4.0/aiortsp/rtsp/__init__.py000066400000000000000000000000711467547026500177750ustar00rootroot00000000000000""" RTSP Connection and session management + parsing """ aiortsp-1.4.0/aiortsp/rtsp/auth.py000066400000000000000000000116311467547026500172030ustar00rootroot00000000000000""" RTSP Authentication module. --------------------------- Implements Basic and Digest authentication. """ import hashlib from base64 import b64encode from typing import Callable from urllib.request import parse_http_list DIGEST_METHODS = { 'MD5': hashlib.md5, 'SHA': hashlib.sha1, 'SHA-256': hashlib.sha256, 'SHA-512': hashlib.sha512 } class Auth: """ Base class for authentication """ def __init__(self, max_retry=1): self.max_retry = max_retry self.retry_count = 0 def handle_ok(self, headers): # pylint: disable=unused-argument """ A response was successful with this authentication. Reset retry count :return: """ self.retry_count = 0 def handle_401(self, headers): # pylint: disable=unused-argument """ :returns True if retry is allowed """ self.retry_count += 1 return self.retry_count <= self.max_retry def make_auth(self, method, url, headers): """ Append authorization header """ raise NotImplementedError class BasicAuth(Auth): """ Implementation of Basic authentication """ def __init__(self, username, password, max_retry=1): super().__init__(max_retry) self.username = username self.password = password def make_auth(self, method, url, headers): b64 = b64encode(f'{self.username}:{self.password}'.encode()) headers['Authorization'] = f'Basic {b64.decode()}' class DigestAuth(Auth): """ Implementation of Digest algorithm """ def __init__(self, username, password, max_retry=1): super().__init__(max_retry) self.username = username self.password = password self.info = None @staticmethod def _parse_digest_header(header): fields = {} fields_ = parse_http_list(header) for field in fields_: k, v = field.split('=', 1) v = v.strip() if v and v[0] == v[-1] == '"': v = v[1:-1] fields[k.strip().lower()] = v return fields @staticmethod def _digest_function(algorithm: str) -> Callable[[str], str]: """ Select the right digest function """ assert algorithm in DIGEST_METHODS, f'algorithm {algorithm} not found' hashlib_digest = DIGEST_METHODS[algorithm] return lambda x: hashlib_digest(x.encode('utf-8')).hexdigest() def _prepare_digest_header(self, method: str, url: str) -> dict: """ Prepare response header and return a dict; meant for ease of testing """ assert self.info algorithm = self.info.get('algorithm', 'MD5').upper() realm = self.info.get('realm') nonce = self.info.get('nonce') opaque = self.info.get('opaque') hash_digest = self._digest_function(algorithm) A1 = '%s:%s:%s' % (self.username, realm, self.password) A2 = '%s:%s' % (method, url) HA1 = hash_digest(A1) HA2 = hash_digest(A2) # Direct response as per RFC 2069 - 2.1.1 response = hash_digest(f'{HA1}:{nonce}:{HA2}') base = { 'username': self.username, 'realm': realm, 'nonce': nonce, 'uri': url, 'response': response } if opaque: base['opaque'] = opaque return base def _build_digest_header(self, method: str, url: str) -> str: base = self._prepare_digest_header(method, url) opts = ", ".join(f'{k}="{v}"' for k, v in base.items()) return f'Digest {opts}' def handle_401(self, headers: dict): """ Takes the given response and tries digest-auth, if needed. :rtype: requests.Response """ auth_header = headers['www-authenticate'] if isinstance(auth_header, list): auth_header = next(header for header in auth_header if header.startswith('Digest ')) # @TODO There may be several Digest propositions (MD5, SHA-256, ...) assert auth_header, 'unable to find a Digest header' self.info = self._parse_digest_header(auth_header[6:]) return super().handle_401(headers) def handle_ok(self, headers: dict): """ A response was successful with this authentication. Reset retry count :return: """ if 'authentication-info' in headers: info = self._parse_digest_header(headers['authentication-info']) if 'nextnonce' in info: self.info['nonce'] = info['nextnonce'] super().handle_ok(headers) def make_auth(self, method: str, url: str, headers: dict): """ Add Authorization to the headers of given request :param method: :param url: :param headers: :return: """ if self.info: headers['Authorization'] = self._build_digest_header(method, url) aiortsp-1.4.0/aiortsp/rtsp/connection.py000066400000000000000000000260511467547026500204030ustar00rootroot00000000000000""" Asyncio RTSP Connection module This is the main interesting part for the user. """ import asyncio import logging import traceback from typing import Callable from aiortsp.__version__ import __version__ from .auth import BasicAuth, DigestAuth from .errors import RTSPResponseError, RTSPConnectionError, RTSPTimeoutError from .parser import RTSPParser, RTSPRequest, RTSPResponse, RTSPBinary _logger = logging.getLogger('rtsp_client') LINE_SPLIT_STR = '\r\n' HEADER_END_STR = LINE_SPLIT_STR * 2 USER_AGENT = f'aiortsp/{__version__}' class RTSPConnection(asyncio.Protocol): """ Creates an RTSP connection for asyncio usage. It's as easy as: async with RTSPConnection(...) as conn: # Here you go, do your RTSP stuff resp = await conn.send_request('DESCRIBE', url) # Cleans properly the connection before leaving the context """ def __init__(self, host, port, username=None, password=None, accept_auth=None, logger=None, ssl=None, timeout=10): self.host = host self.port = port self.username = username self.password = password self.accept_auth = [auth.lower() for auth in accept_auth] if accept_auth else ['basic', 'digest'] self.default_timeout = timeout self.logger = logger or _logger self.ssl = ssl self._transport = None self.result = asyncio.Future() self.pending_msg = None self.active_requests = {} self._cseq = 1 self._auth = None self.parser = RTSPParser() self.binary_handlers = {} async def __aenter__(self): await self.prepare() return self async def __aexit__(self, exc_type, exc_val, exc_tb): self.close() async def prepare(self): """ Prepare connection :return: """ loop = asyncio.get_event_loop() try: await asyncio.wait_for( loop.create_connection(lambda: self, self.host, self.port, ssl = self.ssl), self.default_timeout ) except (asyncio.TimeoutError, OSError) as to: raise RTSPConnectionError(f'Unable to connect to {self.host}:{self.port}') from to def close(self): """ Close connection """ if self._transport: self._transport.close() def register_binary_handler(self, callback: Callable) -> int: """ Register a binary callback. Return the ID which will be used for given protocol """ idx = next(i for i in range(257) if i not in self.binary_handlers) assert idx < 256, 'not any binary handle left' self.binary_handlers[idx] = callback return idx def connection_made(self, transport): """Conforms asyncio.Protocol""" self._transport = transport def connection_lost(self, exc): """Conforms asyncio.Protocol""" self.logger.info('connection to RTSP server %s:%s closed (error: %s)', self.host, self.port, exc) self._transport = None if not self.result.done(): if exc: error = RTSPConnectionError(f'RTSP connection lost: {exc}') error.__cause__ = exc self.result.set_exception(error) else: self.result.set_result('ok') # Close any pending request error = self.result.exception() if not self.result.cancelled() else None for request in self.active_requests.values(): # type: asyncio.Future if not request.done(): request.set_exception(error or RTSPConnectionError('connection closed')) @property def running(self) -> bool: """ Tells if currently running, ie if we have an open transport. """ return self._transport is not None def on_binary(self, binary: RTSPBinary): """Handler for binary data received""" if binary.id in self.binary_handlers: # Call handler self.binary_handlers[binary.id](binary) else: self.logger.debug('BINARY data (%s bytes): %s', binary.length, binary.content) def on_response(self, response: RTSPResponse): """Handler for response received""" self.logger.debug('RESPONSE received: %s\n%s', response, response.content) def on_request(self, request: RTSPRequest): """ Handle a request from server. Override for more fancy controls """ self.logger.warning('request message received during session:\n%s', request.content) # @TODO We do not support requests for now: 551 self.send_response(request, 551, 'Option not supported') def data_received(self, data: bytes): """Parse and distribute received messages""" try: # self.logger.debug('<<< receiving data: %s', data) for msg in self.parser.parse(data): # self.logger.debug('message done: %s', msg) if msg.type == 'response': self.on_response(msg) if msg.cseq in self.active_requests: self.active_requests[msg.cseq].set_result(msg) elif msg.type == 'binary': self.on_binary(msg) elif msg.type == 'request': self.on_request(msg) return except Exception as ex: # pylint: disable=broad-except self.logger.error('error on received data: %s\n%s', ex.__class__.__name__, data) error = RTSPConnectionError('invalid data received from RTSP connection') error.__cause__ = ex self.result.set_exception(error) if self._transport: self._transport.close() traceback.print_exc() def _next_seq(self): cseq = self._cseq self._cseq += 1 return cseq def handle_401(self, resp: RTSPResponse): """ Handle a 401 (Unauthorized) message. :param resp: Response from server containing challenge (digest, basic) :return: True if client is authorized to retry """ if not self._auth and self.username and self.password: # # No authentication selected yet: use one! # if not (self.username and self.password): # raise RTSPResponseError('No valid Authentication provided (username/password)', resp) # Check what is supported www_auth = resp.headers.get('www-authenticate') if not www_auth: raise RTSPResponseError('Invalid 401 response received (no www-authenticate)', resp) if not isinstance(www_auth, list): www_auth = [www_auth] self.logger.debug('authorization attempt, allowed: %s, proposed: %s', self.accept_auth, www_auth) if any(a.startswith('Basic ') for a in www_auth) and 'basic' in self.accept_auth: self.logger.debug('selecting BASIC authentication') self._auth = BasicAuth(self.username, self.password) elif any(a.startswith('Digest ') for a in www_auth) and 'digest' in self.accept_auth: self.logger.debug('selecting DIGEST authentication') self._auth = DigestAuth(self.username, self.password) if self._auth: return self._auth.handle_401(resp.headers) return False def handle_ok(self, resp: RTSPResponse): """ Handle an OK (in terms of authentication) response. There may be extra pieces of information for Auth. :param resp: :return: """ if self._auth: self._auth.handle_ok(resp.headers) def send_message(self, msg, cseq, headers, body: bytes = None): """ Send a 'message' (request or reply) with given cseq and headers """ if not self._transport: self.logger.error('transport is closed') return # Always write CSeq first msg += f'{LINE_SPLIT_STR}CSeq: {cseq}' headers['User-Agent'] = USER_AGENT if body: headers['Content-Length'] = len(body) for k, v in headers.items(): msg += f'{LINE_SPLIT_STR}{k}: {v}' msg += HEADER_END_STR # End of headers data = msg.encode() if body: data += body self._transport.write(data) self.logger.debug('>>> sending msg:\n%s\n', msg) async def send_request(self, method, url, headers=None, timeout=None, body: bytes = None) -> RTSPResponse: """ Send an RTSP request. :param method: RTSP method to be sent :param url: URL to be given in the RTSP request header :param headers: dict of optional headers to add :param timeout: timeout for getting a response :param body: Content body. If specified, a 'Content-Type' header should be added. :return: RTSPResponse """ request = f'{method} {url} RTSP/1.0' if headers is None: headers = {} if self._auth: self._auth.make_auth(method, url, headers) cseq = self._next_seq() self.active_requests[cseq] = asyncio.Future() try: self.send_message(request, cseq, headers, body) resp = await asyncio.wait_for(self.active_requests[cseq], timeout or self.default_timeout) if resp.status == 401: self.logger.debug('unauthorized; see if we can try to authenticate') retry = self.handle_401(resp) if retry: return await self.send_request(method, url, headers, timeout, body) else: # Response was successful (or at least not unauthorized...) self.handle_ok(resp) if not 200 <= resp.status < 300: raise RTSPResponseError(f'RTSP request error for {method} {url}', resp) return resp except asyncio.TimeoutError as to: raise RTSPTimeoutError('RTSP server failed to answer in time') from to finally: # Always cleanup the active request we had registered self.active_requests.pop(cseq, None) def send_response(self, request: RTSPRequest, code, msg, headers=None): """ Send a response message to given request """ if not self._transport: self.logger.error('transport is closed') return response = f'RTSP/1.0 {code} {msg}' if headers is None: headers = {} if 'session' in request.headers: headers['Session'] = request.headers['session'] self.send_message(response, request.cseq, headers) def send_binary(self, idx: int, data: bytes): """ Send a binary interleaved packet """ if not self._transport: self.logger.error('transport is closed') return assert 0 <= idx < 256, f'invalid binary index: {idx}' m_len = len(data) msg = bytearray([ord('$'), idx, (m_len & 0xFF00) >> 8, m_len & 0xFF]) msg += data self._transport.write(msg) self.logger.debug('>>> sending binary: %s', msg) aiortsp-1.4.0/aiortsp/rtsp/errors.py000066400000000000000000000024071467547026500175570ustar00rootroot00000000000000""" RTSP Module errors """ class RTSPError(Exception): """Base RTSP error""" type = 'unknown' def reason(self) -> dict: """ Structured data for RTSP errors, including type, and potentially type-specific extra values. """ return { 'type': self.type } class RTSPResponseError(RTSPError): """ Error upon response. If the response is provided, will be printed """ type = 'response' def __init__(self, msg, response=None): self.response = response super().__init__(msg, self.reason()) def __str__(self): msg = f'REASON: {super().__str__()}' if self.response: msg += f'\nRESPONSE: {self.response}' if self.response.content: msg += f'\nCONTENT: {self.response.content}' return msg def reason(self): r = super().reason() if self.response: r['status'] = self.response.status r['message'] = self.response.status_msg return r class RTSPConnectionError(RTSPError): """ RTSP connection error """ type = 'connection' class RTSPTimeoutError(RTSPError): """ A request timed out (no answer we could understand) """ type = 'timeout' aiortsp-1.4.0/aiortsp/rtsp/parser.py000066400000000000000000000240641467547026500175420ustar00rootroot00000000000000""" RTSP Stream parsing module """ from abc import abstractmethod from binascii import hexlify from io import BytesIO from typing import Tuple, Iterator from .errors import RTSPResponseError CRLF = b'\r\n' class RTSPMessage: """ Base return class for any parsed object coming from an RTSP connection. """ type = 'unknown' @abstractmethod def feed(self, data: bytes) -> Tuple[bytes, bool]: """ Feed part with a line. :returns remaining bytes and True if done parsing current packet """ @property def content(self) -> str: """Extract data from buffer as utf-8""" raise NotImplementedError @property def data(self) -> bytes: """Extract data from buffer as bytes""" raise NotImplementedError @property def length(self) -> int: """Return content length""" raise NotImplementedError class HTTPLikeMsg(RTSPMessage): """ Base class for both RTSP request & reply. Only the first line differs. """ def __init__(self, buffer_size=2**16): self.headerlist = [] self.headers = None self._untreated_data = b'' self._data = None self.size = 0 self._buf = '' self.buffer_size = buffer_size self.cseq = None self.content_type = None self.content_length = -1 self.first_line = None @abstractmethod def parse_first_line(self, line: str): """ Parse the first line received """ def feed(self, data: bytes) -> Tuple[bytes, bool]: """ Feed part with a line. :returns remaining bytes and True if done parsing current packet """ done = False # Prepend any leftover from previous message data, self._untreated_data = self._untreated_data + data, b'' while data and not done: if not self._data: # Still a header if b'\r\n' not in data: # We don't have yet a full header: will have next time (hopefully) self._untreated_data = data data = b'' break line, data = data.split(b'\r\n', 1) done = self.parse_header(line.decode('utf-8') + '\r\n') else: data, done = self.parse_body(data) return data, done def parse_header(self, line) -> bool: """ Add a header line :returns True if message is finished (no content) """ if self.first_line is None: self.parse_first_line(line) return False line = line.strip() if not line: # blank line -> end of header segment return self.finish_header() if line[0] in ' \t' and self.headerlist: name, value = self.headerlist.pop() self.headerlist.append((name, value + line.strip())) else: if ':' not in line: raise RTSPResponseError("Syntax error in header: No colon.") name, value = line.split(':', 1) self.headerlist.append((name.strip().lower(), value.strip())) return False def parse_body(self, data: bytes) -> Tuple[bytes, bool]: """ Add data to the body. :returns (data, done) with: - data as leftover bytes after end of body - done a boolean True when body is complete """ # Do we have too much data? rem_data = self.content_length - self.size assert rem_data > 0, 'we should not be here if already done' if len(data) > rem_data: leftover = data[rem_data:] data = data[:rem_data] else: leftover = b'' self.size += len(data) self._data.write(data) if self.size > self.buffer_size: raise RTSPResponseError('Size of body exceeds maximum buffer size') return leftover, self.size == self.content_length def finish_header(self) -> bool: """Last header received; create buffer and extra useful info""" self.headers = {} for k, v in self.headerlist: if k in self.headers: if not isinstance(self.headers[k], list): self.headers[k] = [self.headers[k]] self.headers[k].append(v) else: self.headers[k] = v self.content_type = self.headers.get('content-type', self.content_type) self.content_length = int(self.headers.get('content-length', '0')) self.cseq = int(self.headers.get('cseq', '-1')) has_content = self.content_length > 0 if has_content: # soon: if has_content := self.content_length > 0 self._data = BytesIO() return not has_content @property def length(self) -> int: """Data length""" return len(self.data) @property def data(self) -> bytes: """Extract data from buffer as bytes""" val = b'' if self._data: pos = self._data.tell() self._data.seek(0) try: val = self._data.read() finally: self._data.seek(pos) return val @property def content(self) -> str: """Extract data from buffer as utf-8""" return self.data.decode('utf-8') class RTSPRequest(HTTPLikeMsg): """ RTSP Request parser. """ type = 'request' CLIENT_REQUESTS = ( b'OPTIONS', b'DESCRIBE', b'ANNOUNCE', b'SETUP', b'PLAY', b'PAUSE', b'TEARDOWN', b'GET_PARAMETER', b'SET_PARAMETER', b'REDIRECT', b'RECORD', ) def __init__(self, buffer_size=2 ** 16): super().__init__(buffer_size) self.request_url = None self.method = None def parse_first_line(self, line): self.first_line = line self.method, self.request_url, protocol = line.split(None, 2) assert protocol.strip().startswith('RTSP/1.0'), 'RTSP response should start with an RTSP/1.0 protocol marker' def __repr__(self): return f'' class RTSPResponse(HTTPLikeMsg): """ RTSP Response parser. """ type = 'response' def __init__(self, buffer_size=2 ** 16): super().__init__(buffer_size) self.status = None self.status_msg = None def parse_first_line(self, line): assert line.startswith('RTSP/1.0'), 'RTSP response should start with an RTSP/1.0 protocol marker' self.first_line = line _, status, status_msg = line.split(None, 2) self.status = int(status.strip()) self.status_msg = status_msg.strip() def __repr__(self): return f'' class RTSPBinary: """ RTSP inline Binary parser. """ type = 'binary' def __init__(self): self._buf = b'' def feed(self, data): """ Feed part with a line. :returns a frame if done """ self._buf += data # If we did not get enough to read id and length, just return if len(self._buf) < 4: return b'', False if len(self._buf) < (self.length + 4): return b'', False data = self._buf[self.length + 4:] self._buf = self._buf[:self.length + 4] return data, True @property def id(self) -> int: """ RTSP inline binary data all have a reference ID, negociated during SETUP. """ assert len(self._buf) >= 1, 'buffer too short' return self._buf[1] @property def length(self) -> int: """Return frame length""" assert len(self._buf) >= 4, 'buffer too short' return (self._buf[2] << 8) + self._buf[3] @property def content(self) -> str: """Return string representation of content""" return hexlify(self.data).decode('utf-8') @property def data(self) -> bytes: """Extract data from buffer""" return self._buf[4:] def __repr__(self): return f'' class RTSPParser: """ RTSP Stream parser. Keep track if incoming data and yields valid elements upon calls to parse(). """ def __init__(self): self.pending_msg = None self._prev_data = b'' def parse(self, data: bytes) -> Iterator[RTSPMessage]: """ Receiving data :param data: :return: """ while data: if self.pending_msg is None: # Prepend potential leftover from previous data data, self._prev_data = self._prev_data + data, b'' # We need to determine what is coming next if data.startswith(CRLF): # Skip empty lines between items data = data[2:] continue if data.startswith(b'RTSP'): # This is a reply self.pending_msg = RTSPResponse() elif data.startswith(b'$'): self.pending_msg = RTSPBinary() elif data.startswith(RTSPRequest.CLIENT_REQUESTS) or any( req.startswith(data) for req in RTSPRequest.CLIENT_REQUESTS): self.pending_msg = RTSPRequest() elif len(data) > 13: # That's the longest client request we could expect... raise ValueError else: # Received only a chunk of message. Should be better next iteration. Store self._prev_data = data break data, done = self.pending_msg.feed(data) if done: yield self.pending_msg self.pending_msg = None aiortsp-1.4.0/aiortsp/rtsp/reader.py000066400000000000000000000122511467547026500175030ustar00rootroot00000000000000""" Simplified RTP reader """ import asyncio import logging import ssl from time import time from typing import AsyncIterable, Optional from urllib.parse import urlparse from dpkt.rtp import RTP from aiortsp.rtsp.connection import RTSPConnection from aiortsp.rtsp.session import RTSPMediaSession, sanitize_rtsp_url from aiortsp.transport import transport_for_scheme, RTPTransport, RTPTransportClient class RTSPReader(RTPTransportClient): """ Quick wrapper around base functions to start getting frames from an RTSP feed. Usage: .. code-block:: async with RTSPReader('rtsp://foo/bar') as reader: async for pkt in reader.iter_packets(): print(pkt) """ def __init__( self, media_url: str, timeout=10, log_level=20, ssl = None, run_loop=False, **_ ): self.media_url = media_url self.logger = logging.getLogger(__name__) self.logger.setLevel(log_level) self.timeout = timeout self.run_loop = run_loop self.ssl = ssl self.queue: 'asyncio.Queue[RTP]' = asyncio.Queue() self._runner = None self.connection: Optional[RTSPConnection] = None self.transport: Optional[RTPTransport] = None self.session: Optional[RTSPMediaSession] = None self.payload_type = None def handle_rtp(self, rtp: RTP): """Queue packets for the iterator""" if self.payload_type and self.payload_type != rtp.pt: return self.queue.put_nowait(rtp) def on_ready(self, connection: RTSPConnection, transport: RTPTransport, session: RTSPMediaSession): """Handler on ready to play stream, for sub classes to do their initialisation""" if session.sdp: self.payload_type = session.sdp.media_payload_type() transport.subscribe(self) self.connection = connection self.transport = transport self.session = session def handle_closed(self, error): """Handler for connection closed, for sub classes to cleanup their state""" self.logger.info('connection closed, error: %s', error) self.connection = None self.transport = None self.session = None async def run_stream_loop(self): """Run stream as a loop, forever restarting unless if cancelled""" while True: try: await self.run_stream() except asyncio.CancelledError: self.logger.error('Stopping run loop for %s', sanitize_rtsp_url(self.media_url)) break except Exception as ex: # pylint: disable=broad-except self.logger.error('Error on stream: %r. Reconnecting...', ex) await asyncio.sleep(1) async def run_stream(self): """ Setup and play stream, and ensure it stays on. """ self.logger.info('try loading stream %s', sanitize_rtsp_url(self.media_url)) p_url = urlparse(self.media_url) if p_url.scheme == 'rtsps' and not self.ssl: self.ssl = ssl.create_default_context() if p_url.scheme == 'rtsps': default_port = 322 else: default_port = 554 async with RTSPConnection( p_url.hostname, p_url.port or default_port, p_url.username, p_url.password, logger=self.logger, timeout=self.timeout, ssl=self.ssl ) as conn: self.logger.info('connected!') transport_class = transport_for_scheme(p_url.scheme) async with transport_class(conn, logger=self.logger, timeout=self.timeout) as transport: async with RTSPMediaSession(conn, self.media_url, transport=transport, logger=self.logger) as sess: self.on_ready(conn, transport, sess) self.logger.info('playing stream...') await sess.play() try: last_keep_alive = time() while conn.running and transport.running: # Check keep alive now = time() if (now - last_keep_alive) > sess.session_keepalive: await sess.keep_alive() last_keep_alive = now await asyncio.sleep(1) except asyncio.CancelledError: self.logger.info('stopping stream...') raise async def __aenter__(self): self._runner = asyncio.ensure_future( self.run_stream_loop() if self.run_loop else self.run_stream()) self._runner.add_done_callback(lambda *_: self.queue.put_nowait(None)) return self async def __aexit__(self, exc_type, exc_val, exc_tb): if self._runner: self._runner.cancel() async def iter_packets(self) -> AsyncIterable[RTP]: """ Yield RTP packets as they come. User can then do whatever they want, without too much boiler plate. """ while True: pkt = await self.queue.get() if not pkt: break yield pkt aiortsp-1.4.0/aiortsp/rtsp/sdp.py000066400000000000000000000237571467547026500170440ustar00rootroot00000000000000""" Session Description - RFC 4566 Very basic SDP parser ------------------------------------------------------------------------------------------------------ type_ Dictionary key Format of the value ====== ====================== ====================================================================== v "protocol_version" version_number o "origin" ("user", session_id, session_version, "net_type", "addr_type", "addr") s "sessionname" "session name" t & r "time" (starttime, stoptime, [repeat,repeat, ...]) where repeat = (interval,duration,[offset,offset, ...]) a "attribute" "value of attribute" b "bandwidth" (mode, bitspersecond) i "information" "value" e "email" "email-address" u "URI" "uri" p "phone" "phone-number" c "connection" ("net_type", "addr_type", "addr", ttl, groupsize) z "timezone adjustments" [(adj-time,offset), (adj-time,offset), ...] k "encryption" ("method","value") m "media" [media-description, media-description, ... ] see next table for media description structure ====== ====================== ====================================================================== """ import re from typing import Optional class SDP(dict): """ SDP Parser class. Takes an sdp content as an input and split it into various sections, including the different available medias. """ def __init__(self, data: str): super().__init__() self.current_media = None self.parsers = { 'v': self._parse_version, 'o': self._parse_origin, 's': self._parse_session_name, 'i': self._parse_info, 'u': self._parse_uri, 'e': self._parse_email, 'p': self._parse_phone, 'c': self._parse_connection, 'k': self._parse_encryption, 'z': self._parse_timezone, 'm': self._parse_media, 'b': self._parse_bandwidth, 't': self._parse_time, 'r': self._parse_repeats, 'a': self._parse_attributes, } for line in data.splitlines(): self._parseline(line) def _parse_version(self, value): value = int(value) # Only version 0 allowed assert value == 0, 'only SDP version 0 supported' self['version'] = int(value) def _parse_origin(self, value): self['origin'] = value def _parse_session_name(self, value): self['sessionName'] = value def _parse_info(self, value): self['information'] = value def _parse_uri(self, value): self['URI'] = value def _parse_email(self, value): self['email'] = value def _parse_phone(self, value): self['phone'] = value def _parse_connection(self, value): self._element['connection'] = value def _parse_encryption(self, value): method, value = re.match(r"^(clear|base64|uri|prompt)(?:[:](.*))?$", value).groups() self._element["encryption"] = (method, value) def _parse_timezone(self, value): adjustments = [] while value.strip() != "": adjtime, offset, offsetunit, value = re.match(r"^ *(\d+) +([+-]?\d+)([dhms])? *?(.*)$", value).groups() adjtime = int(adjtime) offset = int(offset) * {None: 1, "s": 1, "m": 60, "h": 3600, "d": 86400}[offsetunit] adjustments.append((adjtime, offset)) self._element['timezoneAdjustments'] = adjustments def _parse_media(self, value): media, port, numports, protocol, fmt = re.match( r"^(audio|video|text|application|message) +(\d+)(?:[/](\d+))? +([^ ]+) +(.+)$", value).groups() port = int(port) if numports is None: numports = 1 else: numports = int(numports) self.current_media = { 'type': media, 'port': port, 'numPorts': numports, 'protocol': protocol, 'format': fmt } self.setdefault('medias', []).append(self.current_media) def _parse_bandwidth(self, value): mode, rate = \ re.match(r"^ *((?:AS)|(?:CT)|(?:X-[^:]+)):(\d+) *$", value).groups() bitspersecond = int(rate) * 1000 self._element['bandwidth'] = (mode, bitspersecond) def _parse_time(self, value): start, stop = [int(x) for x in re.match(r"^ *(\d+) +(\d+) *$", value).groups()] self._element['time'] = (start, stop) def _parse_repeats(self, value): terms = re.split(r"\s+", value) parsedterms = [] for term in terms: value, unit = re.match(r"^\d+([dhms])?$", term).groups() value = int(value) * {None: 1, "s": 1, "m": 60, "h": 3600, "d": 86400}[unit] parsedterms.append(value) interval, duration = parsedterms[0], parsedterms[1] offsets = parsedterms[2:] self._element['repeats'] = (interval, duration, offsets) def _parse_attributes(self, value): # Attributes are a=: attr, content = value.split(':', 1) if ':' in value else (value, None) attributes = self._element.setdefault('attributes', {}) # # Special cases if attr == 'framerate': # Content is a framerate as float content = float(content) elif attr == 'framesize': pt, width, height = re.match(r"^ *(\d+) *(\d+)-(\d+) *$", content).groups() content = { 'pt': int(pt), 'width': int(width), 'height': int(height) } elif attr == 'rtpmap': pt, enc, clock = re.match(r"^ *(\d+) *(\S+)/(\d+) *$", content).groups() content = { 'pt': int(pt), 'encoding': enc, 'clockRate': int(clock) } elif attr == 'fmtp': pt, opts = content.split(None, 1) content = { 'pt': int(pt) } for opt in opts.split(';'): if not opt.strip(): # Empty, probably a wrong semicolon at the end... continue k, v = opt.split('=', 1) content[k.strip()] = v.strip() attributes[attr] = content def _parse_unknown(self, type_, value): self._element.setdefault('unknowns', []).append((type_, value)) @property def _element(self): return self if self.current_media is None else self.current_media def _parseline(self, line): match = re.match("^(.)=(.*)", line) if match: type_, value = match.group(1), match.group(2) if type_ in self.parsers: try: self.parsers[type_](value) except Exception: # pylint: disable=broad-except self._parse_unknown(type_, value) else: self._parse_unknown(type_, value) @staticmethod def mix_url_control(base: str, ctrl) -> str: """ Given a base URL and a control attribute, build an URL to be used during SETUP. :param base: Base URL (either from user or returned in Content-Base) :param ctrl: Control attributes for given media (or global) :return: URL """ if not ctrl or ctrl == '*': return base if ctrl.startswith('rtsp://') or ctrl.startswith('rtsps://'): return ctrl if not ctrl.startswith('/') and not base.endswith('/'): return base + '/' + ctrl return base + ctrl def get_media(self, media_type='video', media_idx=0): """ Return the Nth media description matching requested type :param media_type: :param media_idx: :return: """ current_idx = 0 for media in self.get('medias', []): if media['type'] != media_type: continue if current_idx < media_idx: current_idx += 1 continue # Found it! return media return None def setup_url(self, base_url: str, media_type='video', media_idx=0) -> str: """ Return the URL to be used for setup. :param base_url: (url requested or returned base url) :param media_type: audio|video|text|application|message :param media_idx: index if multiple medias are available :return: corrected URL """ # Check global control base_url = self.mix_url_control(base_url, self.get('attributes', {}).get('control')) # Look for media media = self.get_media(media_type, media_idx) if media: return self.mix_url_control(base_url, media.get('attributes', {}).get('control')) # Not found in medias return base_url def media_clock_rate(self, media_type='video', media_idx=0) -> Optional[int]: """ Return clock rate of given media """ media = self.get_media(media_type, media_idx) if media: return media.get('attributes', {}).get('rtpmap', {}).get('clockRate') return None def media_payload_type(self, media_type='video', media_idx=0) -> Optional[int]: """ Return clock rate of given media """ media = self.get_media(media_type, media_idx) if media: return media.get('attributes', {}).get('rtpmap', {}).get('pt') return None def guess_h264_props(self, media_idx=0): """ Try to guess H264 `sprop-parameter-sets` :param media_idx: :return: props string """ media = self.get_media(media_type='video', media_idx=media_idx) if media: return media.get('attributes', {}).get('fmtp', {}).get('sprop-parameter-sets') return None aiortsp-1.4.0/aiortsp/rtsp/session.py000066400000000000000000000177571467547026500177440ustar00rootroot00000000000000""" RTSP Media Session setup and control """ import asyncio import calendar import json import logging import math import re from datetime import datetime from typing import Set from urllib.parse import urlparse from aiortsp.rtcp.stats import RTCPStats from aiortsp.transport import RTPTransport from .errors import RTSPError from .parser import RTSPResponse from .sdp import SDP default_logger = logging.getLogger(__name__) def sanitize_rtsp_url(url: str) -> str: """ Sanitize an RTSP url, removing exotic scheme and authentication. """ p_url = urlparse(url) scheme = p_url.scheme if scheme != 'rtsp' and scheme != 'rtsps': scheme = 'rtsp' return p_url._replace( scheme=scheme, netloc=f'{p_url.hostname}' if p_url.port is None else f'{p_url.hostname}:{p_url.port}' ).geturl() class RTSPMediaSession: """ RTSP Media Session TODO Refactor to support multiple medias """ def __init__(self, connection, media_url, transport: RTPTransport, media_type='video', logger=None): self.connection = connection self.media_url = sanitize_rtsp_url(media_url) self.transport = transport self.media_type = media_type self.logger = logger or default_logger self.is_setup = False self.sdp = None self.server_rtp = None self.server_rtcp = None self.session_id = None self.session_keepalive = 60 self.session_options: Set[str] = set() async def __aenter__(self): """ At entrance of env, we expect the stream to be ready for playback """ await self.setup() return self async def __aexit__(self, exc_type, exc_val, exc_tb): if exc_val and exc_type != asyncio.CancelledError: self.logger.error('exception during session: %s %s', exc_type, exc_val) await self.teardown() async def setup(self): """ Perform SETUP """ # Get supported options resp = await self._send('OPTIONS', url=self.media_url) self.save_options(resp) # Get SDP resp = await self._send('DESCRIBE', headers={ 'Accept': 'application/sdp' }) if 'content-base' in resp.headers: self.media_url = resp.headers['content-base'] self.logger.info('using base url: %s', self.media_url) self.logger.debug('received SDP:\n%s', resp.content) self.sdp = SDP(resp.content) self.logger.debug('parsed SDP:\n%s', json.dumps(self.sdp, indent=2)) setup_url = self.sdp.setup_url(self.media_url, media_type=self.media_type) self.logger.info('setting up using URL: %s', setup_url) # --- SETUP RTSP/1.0 --- headers = {} self.transport.on_transport_request(headers) resp = await self.connection.send_request('SETUP', url=setup_url, headers=headers) self.transport.on_transport_response(resp.headers) self.logger.info('stream correctly setup: %s', resp) # Store session ID self.save_session(resp) # Warm up transport await self.transport.warmup() @property def stats(self) -> RTCPStats: """Stats convenient accessor""" return self.transport.stats def save_options(self, resp: RTSPResponse): """ Extract method lists from OPTIONS response """ # Extract session Id if 'public' not in resp.headers: raise RTSPError('error on OPTIONS: `Public` not found') self.session_options = {o.strip().upper() for o in resp.headers['public'].split(',')} self.logger.info('session options: %s', self.session_options) def save_session(self, resp: RTSPResponse): """ Extract session ID and timeout """ # Extract session Id if 'session' not in resp.headers: raise RTSPError('error on SETUP: session not found') # Get session id session_params = resp.headers['session'].split(';') self.session_id = session_params[0].strip() timeout = 60 if len(session_params) > 1: for option in session_params[1:]: option = option.strip() if not option.startswith('timeout'): continue _, timeout_ = option.split('=', 1) timeout = int(timeout_) self.session_keepalive = int(timeout * 0.9) self.logger.info( 'session id: %s, timeout: %s, keep_alive: %s', self.session_id, timeout, self.session_keepalive ) async def teardown(self): """ Perform TEARDOWN """ if self.connection.running: self.logger.info('stopping session/playback...') resp = await self._send('TEARDOWN') self.logger.debug('response to teardown: %s', resp) return resp self.logger.info('session closed (no transport)') async def _send(self, method, url=None, headers=None): if headers is None: headers = {} if self.session_id: headers['Session'] = self.session_id return await self.connection.send_request(method, url or self.media_url, headers) @staticmethod def ts_to_clock(seek: float) -> str: """ Must return a string in the following format: 20190322T043720.003Z :param seek: utc timestamp """ res = datetime.utcfromtimestamp(seek).strftime('%Y%m%dT%H%M%S') rem = seek - math.floor(seek) if rem: res += str(round(rem, 3))[1:] res += 'Z' return res @staticmethod def response_to_ts(resp, default_ts): """ Try to return real play time, or default if not found """ try: res = re.match(r'^ *clock *= *(?P\d{8})T(?P