pax_global_header 0000666 0000000 0000000 00000000064 14474312326 0014520 g ustar 00root root 0000000 0000000 52 comment=13c3fb8b393777ec055188051088e8252736d036
marss-aiortsp-13c3fb8/ 0000775 0000000 0000000 00000000000 14474312326 0014737 5 ustar 00root root 0000000 0000000 marss-aiortsp-13c3fb8/.gitignore 0000664 0000000 0000000 00000002305 14474312326 0016727 0 ustar 00root root 0000000 0000000 # 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/
marss-aiortsp-13c3fb8/.travis.yml 0000664 0000000 0000000 00000000507 14474312326 0017052 0 ustar 00root root 0000000 0000000 sudo: 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:
- coveralls marss-aiortsp-13c3fb8/CHANGELOG.rst 0000664 0000000 0000000 00000002755 14474312326 0016771 0 ustar 00root root 0000000 0000000 ================
Versions history
================
This repository follows changelog_.
**Semantic versioning** will be followed as soon as stable enough, and will reach 1.0.0.
[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/
marss-aiortsp-13c3fb8/LICENSE 0000664 0000000 0000000 00000016744 14474312326 0015760 0 ustar 00root root 0000000 0000000 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.
marss-aiortsp-13c3fb8/README.rst 0000664 0000000 0000000 00000002502 14474312326 0016425 0 ustar 00root root 0000000 0000000 RTSP 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())
marss-aiortsp-13c3fb8/aiortsp/ 0000775 0000000 0000000 00000000000 14474312326 0016420 5 ustar 00root root 0000000 0000000 marss-aiortsp-13c3fb8/aiortsp/__init__.py 0000664 0000000 0000000 00000000100 14474312326 0020520 0 ustar 00root root 0000000 0000000 """
Asyncio RTSP Library
See README.rst for usage examples
"""
marss-aiortsp-13c3fb8/aiortsp/__version__.py 0000664 0000000 0000000 00000000057 14474312326 0021255 0 ustar 00root root 0000000 0000000 """
aiortsp version
"""
__version__ = "1.3.7"
marss-aiortsp-13c3fb8/aiortsp/rtcp/ 0000775 0000000 0000000 00000000000 14474312326 0017370 5 ustar 00root root 0000000 0000000 marss-aiortsp-13c3fb8/aiortsp/rtcp/__init__.py 0000664 0000000 0000000 00000000066 14474312326 0021503 0 ustar 00root root 0000000 0000000 """
RTP Management module.
----------------------
"""
marss-aiortsp-13c3fb8/aiortsp/rtcp/parser.py 0000664 0000000 0000000 00000024150 14474312326 0021240 0 ustar 00root root 0000000 0000000 """
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
marss-aiortsp-13c3fb8/aiortsp/rtcp/stats.py 0000664 0000000 0000000 00000015253 14474312326 0021106 0 ustar 00root root 0000000 0000000 """
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
marss-aiortsp-13c3fb8/aiortsp/rtsp/ 0000775 0000000 0000000 00000000000 14474312326 0017410 5 ustar 00root root 0000000 0000000 marss-aiortsp-13c3fb8/aiortsp/rtsp/__init__.py 0000664 0000000 0000000 00000000071 14474312326 0021517 0 ustar 00root root 0000000 0000000 """
RTSP Connection and session management + parsing
"""
marss-aiortsp-13c3fb8/aiortsp/rtsp/auth.py 0000664 0000000 0000000 00000011631 14474312326 0020725 0 ustar 00root root 0000000 0000000 """
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)
marss-aiortsp-13c3fb8/aiortsp/rtsp/connection.py 0000664 0000000 0000000 00000025770 14474312326 0022134 0 ustar 00root root 0000000 0000000 """
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, 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._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),
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)
marss-aiortsp-13c3fb8/aiortsp/rtsp/errors.py 0000664 0000000 0000000 00000002407 14474312326 0021301 0 ustar 00root root 0000000 0000000 """
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'
marss-aiortsp-13c3fb8/aiortsp/rtsp/parser.py 0000664 0000000 0000000 00000024064 14474312326 0021264 0 ustar 00root root 0000000 0000000 """
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
marss-aiortsp-13c3fb8/aiortsp/rtsp/reader.py 0000664 0000000 0000000 00000011556 14474312326 0021234 0 ustar 00root root 0000000 0000000 """
Simplified RTP reader
"""
import asyncio
import logging
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,
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.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)
async with RTSPConnection(
p_url.hostname, p_url.port or 554,
p_url.username, p_url.password,
logger=self.logger, timeout=self.timeout
) 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
marss-aiortsp-13c3fb8/aiortsp/rtsp/sdp.py 0000664 0000000 0000000 00000023720 14474312326 0020554 0 ustar 00root root 0000000 0000000 """
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://'):
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
marss-aiortsp-13c3fb8/aiortsp/rtsp/session.py 0000664 0000000 0000000 00000017616 14474312326 0021460 0 ustar 00root root 0000000 0000000 """
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)
return p_url._replace(
scheme='rtsp',
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