~/bend-docscommunity

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"))