rtsp_core.bend source
rtsp_core.bend on the hub · documented module
# The pure core of the RTSP client (RFC 2326): URLs, requests, what# comes back on the connection (replies, and RTP packets interleaved# with them), authentication (Basic and Digest, RFC 2617), and the# session description (SDP, RFC 4566). Nothing here does IO, so# LAWS.bend can state laws about it; rtsp.bend is the dialog.import Baseimport ./text.bend as Timport ./bytes.bend as Bimport ./b64.bend as Eimport ./md5.bend as Dimport ./sha256.bend as H# A header field.type Hdr is Data: Hdr{name: String, value: String}# URLs# ----# rtsp://[user[:pass]@]host[:port][/path]; no host means not an RTSP URL.type Url is Data: Url{host: String, port: U32, user: String, pass: String, path: String}def Url.none() -> Url: Url{"", 0, "", "", ""}def Url.hex(+c: U32) -> U32: E.Word.pick((c >= 48 && c <= 57 : U32), (c - 48 : U32), E.Word.pick((c >= 65 && c <= 70 : U32), (c - 55 : U32), E.Word.pick((c >= 97 && c <= 102 : U32), (c - 87 : U32), 255)))def Url.unpct.put(ok: Bool, +a: U32, +b: U32, rest: String) -> String: match ok: case True{}: SCon{Chr{(Url.hex(a) * 16 + Url.hex(b) : U32)}, rest} case False{}: SCon{'%', SCon{Chr{a}, SCon{Chr{b}, rest}}}# Percent-decoding (RFC 3986 2.1), for a user or password with "@" or# ":" in it; a "%" not followed by two hex digits stays.def Url.unpct(s: String) -> String: match s: case SNil{}: SNil{} case SCon{Chr{37}, SCon{Chr{+a}, SCon{Chr{+b}, t}}}: Url.unpct.put((Url.hex(a) < 16 && Url.hex(b) < 16 : U32), a, b, Url.unpct(t)) case SCon{c, t}: SCon{c, Url.unpct(t)}def Url.port(m: Maybe<&2, U32>, dflt: U32) -> U32: match m: case None{}: dflt case Some{p}: p# "host[:port]"; dflt: the port when absent.def Url.host(hp: List<&2, String>, dflt: U32, user: String, pass: String, path: String) -> Url: match hp: case Con{h, Nil{}}: Url{h, dflt, user, pass, path} case Con{h, Con{p, Nil{}}}: Url{h, Url.port(U32.read(p), dflt), user, pass, path} case _: Url.none()# "user[:pass]", percent-decoded.def Url.creds(up: List<&2, String>, hp: List<&2, String>, dflt: U32, path: String) -> Url: match up: case Con{u, Nil{}}: Url.host(hp, dflt, Url.unpct(u), "", path) case Con{u, Con{p, rest}}: Url.host(hp, dflt, Url.unpct(u), Url.unpct(String.join(p <> rest, ":")), path) case _: Url.host(hp, dflt, "", "", path)# "[user[:pass]@]host[:port]"; the last "@" divides them.def Url.auth(+ps: List<&2, String>, dflt: U32, path: String) -> Url: +n = Nat.sub(List.length(&2, String, ps), 1n) Url.creds(String.split(String.join(List.take(&2, String, ps, n), "@"), ':'), String.split(String.join(List.drop(&2, String, ps, n), "@"), ':'), dflt, path)def Url.rest(parts: List<&2, String>, dflt: U32) -> Url: match parts: case Con{a, Nil{}}: Url.auth(String.split(a, '@'), dflt, "") case Con{a, rest}: Url.auth(String.split(a, '@'), dflt, "/" ++ String.join(rest, "/")) case Nil{}: Url.none()# The URL after "scheme://" (scheme in lower case, with its "://"), on# its default port when it names none; anything else gives no host.def Url.of(+url: String, +scheme: String, dflt: U32) -> Url: Bool.pick(Url, String.starts_with(String.to_lower(url), scheme), Url.rest(String.split(String.drop(url, String.length(scheme)), '/'), dflt), Url.none())def Url.esc(s: String) -> String: match s: case SNil{}: SNil{} case SCon{Chr{32}, t}: "%20" ++ Url.esc(t) case SCon{c, t}: SCon{c, Url.esc(t)}# The URL as it goes in a request: no user or password, spaces escaped,# no line end.def Url.show(u: Url, scheme: String) -> String: Url{host, port, _, _, path} = u T.Text.clean(scheme ++ host ++ ":" ++ U32.show(port) ++ Url.esc(path))def Url.host.of(u: Url) -> String: Url{h, _, _, _, _} = u hdef Url.port.of(u: Url) -> U32: Url{_, p, _, _, _} = u pdef Url.user(u: Url) -> String: Url{_, _, x, _, _} = u xdef Url.pass(u: Url) -> String: Url{_, _, _, x, _} = u xdef Url.path(u: Url) -> String: Url{_, _, _, _, x} = u x# Requests# --------# A line of a request with no CR or LF in it, whatever went in, and the# CRLF that ends it: no method, URL or header value can start another# line. LAWS.bend proves it for every string.def Rtsp.text(raw: String) -> String: T.Text.clean(raw)def Rtsp.line(raw: String) -> String: Rtsp.text(raw) ++ "\r\n"def Rtsp.fields(hs: List<&2, Hdr>) -> String: match hs: case Nil{}: SNil{} case Con{h, t}: Hdr{n, v} = h Rtsp.line(n ++ ": " ++ v) ++ Rtsp.fields(t)# A request: the request line, CSeq, User-Agent, the given headers, and# the empty line (RFC 2326 6).def Rtsp.request(method: String, uri: String, cseq: U32, hs: List<&2, Hdr>) -> String: Rtsp.line(method ++ " " ++ uri ++ " RTSP/1.0") ++ Rtsp.line("CSeq: " ++ U32.show(cseq)) ++ Rtsp.line("User-Agent: bend-stream") ++ Rtsp.fields(hs) ++ "\r\n"# What arrives# ------------# A reply: its status code (0 for a request the server sends us), its# headers and its body.type Resp is Data: Resp{code: U32, headers: List<&2, Hdr>, body: List<&2, U32>}# The first whole thing in a buffer, and what follows it: a reply; an# interleaved packet ("$", channel, length; RFC 2326 10.12); Need when it# is not all there yet; Bad when it can be neither.type Msg is Data: Need{} Bad{} Reply{resp: Resp, rest: List<&2, U32>} Frame{chan: U32, data: List<&2, U32>, rest: List<&2, U32>}def Hdr.trim(s: String) -> String: String.trim(s)def Hdr.of(parts: List<&2, String>) -> Hdr: match parts: case Con{n, rest}: Hdr{String.to_lower(String.trim(n)), String.trim(String.join(rest, ":"))} case Nil{}: Hdr{"", ""}# The header lines after the first, each as a field with its name in# lower case.def Hdr.parse(ls: List<&2, String>) -> List<&2, Hdr>: match ls: case Nil{}: Nil{} case Con{l, t}: Hdr.of(String.split(l, ':')) <> Hdr.parse(t)# The first value of a header (name in lower case), or "".def Hdr.get(hs: List<&2, Hdr>, +name: String) -> String: match hs: case Nil{}: "" case Con{h, t}: Hdr{n, v} = h Bool.pick(String, String.eq(n, name), v, Hdr.get(t, name))def Hdr.keep(hit: Bool, v: String, rest: List<&2, String>) -> List<&2, String>: match hit: case True{}: v <> rest case False{}: rest# Every value of a header, in order.def Hdr.all(hs: List<&2, Hdr>, +name: String) -> List<&2, String>: match hs: case Nil{}: Nil{} case Con{h, t}: Hdr{n, v} = h Hdr.keep(String.eq(n, name), v, Hdr.all(t, name))def Resp.code(r: Resp) -> U32: Resp{c, _, _} = r cdef Resp.headers(r: Resp) -> List<&2, Hdr>: Resp{_, h, _} = r hdef Resp.body(r: Resp) -> List<&2, U32>: Resp{_, _, b} = r bdef Resp.get(r: Resp, name: String) -> String: Hdr.get(Resp.headers(r), name)def Rtsp.num(m: Maybe<&2, U32>) -> U32: match m: case None{}: 0 case Some{n}: n# The n-th word of a list, or "".def Rtsp.word(ws: List<&2, String>, n: Nat) -> String: match ws n: case Nil{} _: "" case Con{w, _} 0n: w case Con{_, t} 1n+p: Rtsp.word(t, p)# The status of "RTSP/1.0 200 OK"; 0 for any other first line.def Rtsp.status(+ws: List<&2, String>) -> U32: Bool.pick(U32, String.starts_with(Rtsp.word(ws, 0n), "RTSP/"), Rtsp.num(U32.read(Rtsp.word(ws, 1n))), 0)def Rtsp.uncr(s: String) -> String: match s: case SNil{}: SNil{} case SCon{Chr{13}, t}: Rtsp.uncr(t) case SCon{c, t}: SCon{c, Rtsp.uncr(t)}# The end of a message's head: the bytes before the first empty line,# and the bytes after it. acc: the head so far, reversed.def Rtsp.head(bs: List<&2, U32>, acc: List<&2, U32>) -> B.Cut: match bs: case Nil{}: B.Short{} case Con{13, Con{10, Con{13, Con{10, rest}}}}: B.Cut{B.Bytes.rev(acc), rest} case Con{b, t}: Rtsp.head(t, b <> acc)def Rtsp.body(c: B.Cut, code: U32, hs: List<&2, Hdr>) -> Msg: match c: case B.Short{}: Need{} case B.Cut{body, rest}: Reply{Resp{code, hs, body}, rest}def Rtsp.reply.with(code: U32, +hs: List<&2, Hdr>, rest: List<&2, U32>) -> Msg: Rtsp.body(B.Bytes.cut(Rtsp.num(U32.read(Hdr.get(hs, "content-length"))), rest), code, hs)def Rtsp.reply.lines(ls: List<&2, String>, rest: List<&2, U32>) -> Msg: match ls: case Nil{}: Bad{} case Con{first, t}: Rtsp.reply.with(Rtsp.status(String.split(first, ' ')), Hdr.parse(t), rest)def Rtsp.reply(c: B.Cut) -> Msg: match c: case B.Short{}: Need{} case B.Cut{head, rest}: Rtsp.reply.lines(String.split(Rtsp.uncr(B.Bytes.text(head)), '\n'), rest)def Rtsp.frame(chan: U32, c: B.Cut) -> Msg: match c: case B.Short{}: Need{} case B.Cut{data, rest}: Frame{chan, data, rest}def Rtsp.demux.text(letter: Bool, buf: List<&2, U32>) -> Msg: match letter: case True{}: Rtsp.reply(Rtsp.head(buf, Nil{})) case False{}: Bad{}# The first message in the buffer. A "$" opens an interleaved packet;# a letter opens a reply (or a request from the server); the CR and LF# some servers leave between messages are skipped.def Rtsp.demux(buf: List<&2, U32>) -> Msg: match buf: case Nil{}: Need{} case Con{36, Con{chan, Con{a, Con{b, t}}}}: Rtsp.frame(chan, B.Bytes.cut(U32.or(U32.shln(a, 8n), b), t)) case Con{36, _}: Need{} case Con{13, t}: Rtsp.demux(t) case Con{10, t}: Rtsp.demux(t) case Con{+c, t}: Rtsp.demux.text((c >= 65 && c <= 90 : U32), c <> t)# Authentication# --------------# A challenge (WWW-Authenticate): its scheme in lower case ("" for# none) and Digest's parameters.type Chal is Data: Chal{scheme: String, realm: String, nonce: String, opaque: String, qop: String, algo: String}def Chal.none() -> Chal: Chal{"", "", "", "", "", ""}# A parameter list cut at its commas, those inside quotes kept: where# the scan is (in quotes, after a backslash), the item so far (reversed)# and the items (reversed).type Scan is Data: Scan{q: Bool, esc: Bool, cur: String, acc: List<&2, String>}def Chal.scan.ch(st: Scan, +c: U32) -> Scan: Scan{+q, +esc, +cur, +acc} = st +ch = {SCon{Chr{c}, cur} : String} Bool.pick(Scan, esc, Scan{q, False{}, ch, acc}, Bool.pick(Scan, q && U32.is_eq(c, 92), Scan{q, True{}, cur, acc}, Bool.pick(Scan, U32.is_eq(c, 34), Scan{Bool.not(q), False{}, cur, acc}, Bool.pick(Scan, Bool.not(q) && U32.is_eq(c, 44), Scan{q, False{}, "", String.trim(String.reverse(cur)) <> acc}, Scan{q, False{}, ch, acc}))))def Chal.scan.go(s: String, st: Scan) -> Scan: match s: case SNil{}: st case SCon{Chr{c}, t}: Chal.scan.go(t, Chal.scan.ch(st, c))def Chal.scan.fin(st: Scan) -> List<&2, String>: Scan{_, _, cur, acc} = st List.reverse(&2, String, String.trim(String.reverse(cur)) <> acc)# "k=v, k="v, w"" as "k=v" items with the quotes taken off.def Chal.items(s: String) -> List<&2, String>: Chal.scan.fin(Chal.scan.go(s, Scan{False{}, False{}, "", Nil{}}))def Chal.kv(parts: List<&2, String>) -> Hdr: match parts: case Con{k, rest}: Hdr{String.to_lower(String.trim(k)), String.trim(String.join(rest, "="))} case Nil{}: Hdr{"", ""}def Chal.kvs(xs: List<&2, String>) -> List<&2, Hdr>: match xs: case Nil{}: Nil{} case Con{x, t}: Chal.kv(String.split(x, '=')) <> Chal.kvs(t)def Chal.with(scheme: String, +ps: List<&2, Hdr>) -> Chal: Chal{String.to_lower(scheme), Hdr.get(ps, "realm"), Hdr.get(ps, "nonce"), Hdr.get(ps, "opaque"), String.to_lower(Hdr.get(ps, "qop")), String.to_upper(Hdr.get(ps, "algorithm"))}def Chal.words(ws: List<&2, String>) -> Chal: match ws: case Con{scheme, rest}: Chal.with(scheme, Chal.kvs(Chal.items(String.join(rest, " ")))) case Nil{}: Chal.none()# One WWW-Authenticate value.def Chal.of(v: String) -> Chal: Chal.words(String.split(String.trim(v), ' '))def Chal.all(vs: List<&2, String>) -> List<&2, Chal>: match vs: case Nil{}: Nil{} case Con{v, t}: Chal.of(v) <> Chal.all(t)def Chal.scheme(c: Chal) -> String: Chal{s, _, _, _, _, _} = c sdef Chal.algo(c: Chal) -> String: Chal{_, _, _, _, _, a} = c a# Whether this one can be answered: Basic, or Digest with MD5 (the# default when no algorithm is named) or SHA-256 (RFC 7616).def Chal.digest(+c: Chal) -> Bool: String.eq(Chal.scheme(c), "digest") && (String.is_empty(Chal.algo(c)) || String.eq(Chal.algo(c), "MD5") || String.eq(Chal.algo(c), "SHA-256"))def Chal.basic(c: Chal) -> Bool: String.eq(Chal.scheme(c), "basic")def Chal.first(cs: List<&2, Chal>, +digest: Bool) -> Chal: match cs: case Nil{}: Chal.none() case Con{+c, t}: Bool.pick(Chal, Bool.pick(Bool, digest, Chal.digest(c), Chal.basic(c)), c, Chal.first(t, digest))def Chal.or.at(none: Bool, a: Chal, b: Chal) -> Chal: match none: case True{}: b case False{}: adef Chal.or(+a: Chal, b: Chal) -> Chal: Chal.or.at(String.is_empty(Chal.scheme(a)), a, b)# The challenge to answer among a reply's: the first Digest one this# client can answer, if there is one (the password does not travel; a# server lists the one it prefers first, RFC 7616 3.7), else Basic, else# none.def Chal.pick(+cs: List<&2, Chal>) -> Chal: Chal.or(Chal.first(cs, True{}), Chal.first(cs, False{}))def Auth.md5(s: String) -> String: D.Md5.hex(D.Md5.hash(E.Utf8.bytes(s)))def Auth.sha256(s: String) -> String: D.Md5.hex(H.Sha.hash(E.Utf8.bytes(s)))# The hash a Digest challenge names, in hex.def Auth.hash(sha: Bool, s: String) -> String: match sha: case True{}: Auth.sha256(s) case False{}: Auth.md5(s)def Auth.esc(s: String) -> String: match s: case SNil{}: SNil{} case SCon{Chr{34}, t}: "\\\"" ++ Auth.esc(t) case SCon{Chr{92}, t}: "\\\\" ++ Auth.esc(t) case SCon{c, t}: SCon{c, Auth.esc(t)}# A quoted string (RFC 2617 1.2), its quotes and backslashes escaped.def Auth.q(s: String) -> String: "\"" ++ Auth.esc(T.Text.clean(s)) ++ "\""# Digest's response (RFC 2617 3.2.2, RFC 7616 3.4.1): with qop=auth the# nonce count and the client nonce enter the hash; without it, the 2069# form. sha: SHA-256 instead of MD5.def Auth.response(+user: String, +realm: String, pass: String, method: String, +uri: String, +nonce: String, auth: Bool, +cnonce: String, +sha: Bool) -> String: +ha1 = Auth.hash(sha, user ++ ":" ++ realm ++ ":" ++ pass) +ha2 = Auth.hash(sha, method ++ ":" ++ uri) Bool.pick(String, auth, Auth.hash(sha, ha1 ++ ":" ++ nonce ++ ":00000001:" ++ cnonce ++ ":auth:" ++ ha2), Auth.hash(sha, ha1 ++ ":" ++ nonce ++ ":" ++ ha2))def Auth.opt(+v: String, text: String) -> String: Bool.pick(String, String.is_empty(v), "", text)def Auth.digest(c: Chal, +user: String, pass: String, method: String, +uri: String, +cnonce: String) -> String: Chal{_, +realm, +nonce, +opaque, +qop, +algo} = c +auth = String.contains(qop, "auth") "Digest username=" ++ Auth.q(user) ++ ", realm=" ++ Auth.q(realm) ++ ", nonce=" ++ Auth.q(nonce) ++ ", uri=" ++ Auth.q(uri) ++ ", response=\"" ++ Auth.response(user, realm, pass, method, uri, nonce, auth, cnonce, String.eq(algo, "SHA-256")) ++ "\"" ++ Auth.opt(algo, ", algorithm=" ++ algo) ++ Auth.opt(opaque, ", opaque=" ++ Auth.q(opaque)) ++ Bool.pick(String, auth, ", qop=auth, nc=00000001, cnonce=" ++ Auth.q(cnonce), "")def Auth.basic(user: String, pass: String) -> String: "Basic " ++ E.B64.text(user ++ ":" ++ pass)def Auth.value.at(digest: Bool, basic: Bool, c: Chal, user: String, pass: String, method: String, uri: String, cnonce: String) -> String: match digest basic: case True{} _: Auth.digest(c, user, pass, method, uri, cnonce) case False{} True{}: Auth.basic(user, pass) case False{} False{}: ""# The Authorization value that answers a challenge for this request, or# "" when there is none to answer. cnonce: a fresh random string.def Auth.value(+c: Chal, user: String, pass: String, method: String, uri: String, cnonce: String) -> String: Auth.value.at(Chal.digest(c), Chal.basic(c), c, user, pass, method, uri, cnonce)# The headers that carry it: none when the value is empty.def Auth.headers(+v: String) -> List<&2, Hdr>: Bool.pick(List<&2, Hdr>, String.is_empty(v), Nil{}, [Hdr{"Authorization", v}])# Session description# -------------------# A media stream of a description: its kind ("video", "audio"), payload# type, codec ("H264", in upper case) and clock rate (from a=rtpmap),# control URL (a=control) and format parameters (a=fmtp).type Media is Data: Media{kind: String, pt: U32, codec: String, clock: U32, control: String, fmtp: String}# A description: the session's control URL and its streams.type Sdp is Data: Sdp{control: String, medias: List<&2, Media>}def Media.none() -> Media: Media{"", 0, "", 0, "", ""}# The reading so far: the session's control, the streams done# (reversed), the stream being read and whether there is one.type Sdp.St is Data: St{control: String, done: List<&2, Media>, cur: Media, has: Bool}def Sdp.push(has: Bool, cur: Media, done: List<&2, Media>) -> List<&2, Media>: match has: case True{}: cur <> done case False{}: done# "m=video 0 RTP/AVP 96": the kind and the first payload type.def Sdp.m(+ws: List<&2, String>) -> Media: Media{Rtsp.word(ws, 0n), Rtsp.num(U32.read(Rtsp.word(ws, 3n))), "", 0, "", ""}def Sdp.after(s: String, n: Nat) -> String: String.trim(String.drop(s, n))# "96 H264/90000" into the stream, when it is about its payload type.def Sdp.rtpmap(+ws: List<&2, String>, m: Media) -> Media: Media{+kind, +pt, +codec, +clock, +control, +fmtp} = m +enc = String.split(Rtsp.word(ws, 1n), '/') Bool.pick(Media, U32.is_eq(Rtsp.num(U32.read(Rtsp.word(ws, 0n))), pt), Media{kind, pt, String.to_upper(Rtsp.word(enc, 0n)), Rtsp.num(U32.read(Rtsp.word(enc, 1n))), control, fmtp}, Media{kind, pt, codec, clock, control, fmtp})# "96 a=1; b=2" into the stream, when it is about its payload type.def Sdp.fmtp(+v: String, m: Media) -> Media: Media{+kind, +pt, +codec, +clock, +control, +fmtp} = m +ws = String.split(v, ' ') Bool.pick(Media, U32.is_eq(Rtsp.num(U32.read(Rtsp.word(ws, 0n))), pt), Media{kind, pt, codec, clock, control, String.trim(String.join(List.drop(&2, String, ws, 1n), " "))}, Media{kind, pt, codec, clock, control, fmtp})def Sdp.control(v: String, m: Media) -> Media: Media{kind, pt, codec, clock, _, fmtp} = m Media{kind, pt, codec, clock, v, fmtp}def Sdp.line(st: Sdp.St, +l: String) -> Sdp.St: St{+control, +done, +cur, +has} = st Bool.pick(Sdp.St, String.starts_with(l, "m="), St{control, Sdp.push(has, cur, done), Sdp.m(String.split(Sdp.after(l, 2n), ' ')), True{}}, Bool.pick(Sdp.St, String.starts_with(l, "a=control:"), Bool.pick(Sdp.St, has, St{control, done, Sdp.control(Sdp.after(l, 10n), cur), has}, St{Sdp.after(l, 10n), done, cur, has}), Bool.pick(Sdp.St, String.starts_with(l, "a=rtpmap:"), St{control, done, Sdp.rtpmap(String.split(Sdp.after(l, 9n), ' '), cur), has}, Bool.pick(Sdp.St, String.starts_with(l, "a=fmtp:"), St{control, done, Sdp.fmtp(Sdp.after(l, 7n), cur), has}, St{control, done, cur, has}))))def Sdp.lines(ls: List<&2, String>, st: Sdp.St) -> Sdp.St: match ls: case Nil{}: st case Con{l, t}: Sdp.lines(t, Sdp.line(st, l))def Sdp.fin(st: Sdp.St) -> Sdp: St{control, done, cur, has} = st Sdp{control, List.reverse(&2, Media, Sdp.push(has, cur, done))}# A description from its text.def Sdp.of(text: String) -> Sdp: Sdp.fin(Sdp.lines(String.split(Rtsp.uncr(text), '\n'), St{"", Nil{}, Media.none(), False{}}))def Media.kind(m: Media) -> String: Media{k, _, _, _, _, _} = m kdef Media.pt(m: Media) -> U32: Media{_, p, _, _, _, _} = m pdef Media.codec(m: Media) -> String: Media{_, _, c, _, _, _} = m cdef Media.control(m: Media) -> String: Media{_, _, _, _, c, _} = m cdef Media.fmtp(m: Media) -> String: Media{_, _, _, _, _, f} = m fdef Sdp.find(ms: List<&2, Media>, +kind: String) -> Media: match ms: case Nil{}: Media.none() case Con{+m, t}: Bool.pick(Media, String.eq(Media.kind(m), kind), m, Sdp.find(t, kind))# The first stream of a kind (one with no kind when there is none).def Sdp.media(s: Sdp, kind: String) -> Media: Sdp{_, ms} = s Sdp.find(ms, kind)def Sdp.session(s: Sdp) -> String: Sdp{c, _} = s cdef Sdp.param.of(parts: List<&2, String>) -> Hdr: match parts: case Con{k, rest}: Hdr{String.to_lower(String.trim(k)), String.trim(String.join(rest, "="))} case Nil{}: Hdr{"", ""}def Sdp.params(xs: List<&2, String>) -> List<&2, Hdr>: match xs: case Nil{}: Nil{} case Con{x, t}: Sdp.param.of(String.split(x, '=')) <> Sdp.params(t)# A format parameter's value ("a=1; b=2" has "b" as "2"), or "".def Sdp.param(fmtp: String, name: String) -> String: Hdr.get(Sdp.params(String.split(fmtp, ';')), name)def Sdp.sets(xs: List<&2, String>) -> List<&2, List<&2, U32>>: match xs: case Nil{}: Nil{} case Con{x, t}: E.B64.decode(x) <> Sdp.sets(t)def Sdp.some(s: String) -> Bool: Bool.not(String.is_empty(String.trim(s)))# The parameter sets a stream announces, decoded: H.264's SPS and PPS# (sprop-parameter-sets, RFC 6184 8.1), or H.265's VPS, SPS and PPS# (sprop-vps, sprop-sps, sprop-pps; RFC 7798 7.1).def Sdp.sprops(+fmtp: String) -> List<&2, List<&2, U32>>: Sdp.sets(List.filter(~String, ~Sdp.some, List.append(&2, String, String.split(Sdp.param(fmtp, "sprop-parameter-sets"), ','), List.append(&2, String, String.split(Sdp.param(fmtp, "sprop-vps"), ','), List.append(&2, String, String.split(Sdp.param(fmtp, "sprop-sps"), ','), String.split(Sdp.param(fmtp, "sprop-pps"), ','))))))# The URL a control attribute names (RFC 2326 C.1.1): itself when# absolute, the base for "*" or none, else the base and it.def Rtsp.control(+base: String, +control: String) -> String: Bool.pick(String, String.starts_with(String.to_lower(control), "rtsp://"), control, Bool.pick(String, String.is_empty(control) || String.eq(control, "*"), base, Bool.pick(String, String.ends_with(base, "/"), base, base ++ "/") ++ control))# The session id of a Session header ("id;timeout=60").def Rtsp.session(v: String) -> String: String.trim(Rtsp.word(String.split(v, ';'), 0n))def Rtsp.timeout.of(ps: List<&2, String>) -> U32: match ps: case Nil{}: 60 case Con{+p, t}: +kv = String.to_lower(String.trim(p)) Bool.pick(U32, String.starts_with(kv, "timeout="), U32.max(Rtsp.num(U32.read(String.drop(kv, 8n))), 2), Rtsp.timeout.of(t))# The seconds a Session header gives the session without a request# ("id;timeout=30"); 60 when it says nothing (RFC 2326 12.37).def Rtsp.timeout(v: String) -> U32: Rtsp.timeout.of(String.split(v, ';'))def Rtsp.signed.with(+auth: String, method: String, uri: String, cseq: U32, +session: String, hs: List<&2, Hdr>) -> String: Rtsp.request(method, uri, cseq, List.append(&2, Hdr, hs, List.append(&2, Hdr, Bool.pick(List<&2, Hdr>, String.is_empty(session), Nil{}, [Hdr{"Session", session}]), Auth.headers(auth))))# A request for this session: the given headers, the Session header once# there is one, and the Authorization that answers the server's# challenge for this method and URL.def Rtsp.signed(c: Chal, user: String, pass: String, +method: String, +uri: String, cseq: U32, cnonce: String, +session: String, hs: List<&2, Hdr>) -> String: Rtsp.signed.with(Auth.value(c, user, pass, method, uri, cnonce), method, uri, cseq, session, hs)# What is wrong with a description for pulling video, or "": there must# be a video stream, in H.264 or H.265.def Sdp.problem(+m: Media) -> String: Bool.pick(String, String.is_empty(Media.kind(m)), "the stream has no video", Bool.pick(String, String.eq(Media.codec(m), "H264") || String.eq(Media.codec(m), "H265"), "", "the video is " ++ Media.codec(m) ++ ", not H.264 or H.265"))