From 651941acd16c2414a824e3fd00641061cbca310f Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 6 Jun 2022 22:50:28 +0200 Subject: [PATCH] Implement experimental mp4 streaming --- src/routes/media.nim | 121 ++++++++++++++++++++++++------------------- src/utils.nim | 15 ++++++ src/views/tweet.nim | 13 ++--- 3 files changed, 88 insertions(+), 61 deletions(-) diff --git a/src/routes/media.nim b/src/routes/media.nim index e63a0f8..705da23 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -12,6 +12,7 @@ export httpclient, os, strutils, asyncstreams, base64, re const m3u8Mime* = "application/vnd.apple.mpegurl" + mp4Mime* = "video/mp4" maxAge* = "max-age=604800" proc safeFetch*(url: string): Future[string] {.async.} = @@ -20,56 +21,81 @@ proc safeFetch*(url: string): Future[string] {.async.} = except: discard finally: client.close() -template respond*(req: asynchttpserver.Request; headers) = - var msg = "HTTP/1.1 200 OK\c\L" - for k, v in headers: +template respond*(req: asynchttpserver.Request; code: HttpCode; + headers: seq[(string, string)]) = + var msg = "HTTP/1.1 " & $code & "\c\L" + for (k, v) in headers: msg.add(k & ": " & v & "\c\L") msg.add "\c\L" - yield req.client.send(msg) + yield req.client.send(msg, flags={}) + +proc getContentLength(res: AsyncResponse): string = + result = "0" + if res.headers.hasKey("content-length"): + result = $res.contentLength + elif res.headers.hasKey("content-range"): + result = res.headers["content-range"] + result = result[result.find('/') + 1 .. ^1] + if result == "*": + result.setLen(0) proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = result = Http200 + let request = req.getNativeReq() - client = newAsyncHttpClient() + hashed = $hash(url) + + if request.headers.getOrDefault("If-None-Match") == hashed: + return Http304 + + let c = newAsyncHttpClient(headers=newHttpHeaders({ + "accept": "*/*", + "range": $req.headers.getOrDefault("range") + })) try: - let res = await client.get(url) - if res.status != "200 OK": + var res = await c.get(url) + if not res.status.startsWith("20"): return Http404 - let hashed = $hash(url) - if request.headers.getOrDefault("If-None-Match") == hashed: - return Http304 - - let contentLength = - if res.headers.hasKey("content-length"): - res.headers["content-length", 0] - else: - "" - - let headers = newHttpHeaders({ + var headers = @{ + "Accept-Ranges": "bytes", "Content-Type": res.headers["content-type", 0], - "Content-Length": contentLength, - "Cache-Control": maxAge, - "ETag": hashed - }) + "Cache-Control": maxAge + } - respond(request, headers) + var tries = 0 + while tries <= 10 and res.headers.hasKey("transfer-encoding"): + await sleepAsync(100 + tries * 200) + res = await c.get(url) + tries.inc + + let contentLength = res.getContentLength + if contentLength.len > 0: + headers.add ("Content-Length", contentLength) + + if res.headers.hasKey("content-range"): + headers.add ("Content-Range", $res.headers.getOrDefault("content-range")) + respond(request, Http206, headers) + else: + respond(request, Http200, headers) var (hasValue, data) = (true, "") while hasValue: (hasValue, data) = await res.bodyStream.read() if hasValue: - await request.client.send(data) + await request.client.send(data, flags={}) data.setLen 0 - except HttpRequestError, ProtocolError, OSError: + except OSError: discard + except ProtocolError, HttpRequestError: result = Http404 finally: - client.close() + c.close() -template check*(code): untyped = +template check*(c): untyped = + let code = c if code != Http200: resp code else: @@ -83,37 +109,27 @@ proc decoded*(req: jester.Request; index: int): string = if based: decode(encoded) else: decodeUrl(encoded) +proc getPicUrl*(req: jester.Request): string = + result = decoded(req, 1) + if "twimg.com" notin result: + result.insert(twimg) + if not result.startsWith(https): + result.insert(https) + proc createMediaRouter*(cfg: Config) = router media: get "/pic/?": resp Http404 - get re"^\/pic\/orig\/(enc)?\/?(.+)": - var url = decoded(request, 1) - if "twimg.com" notin url: - url.insert(twimg) - if not url.startsWith(https): - url.insert(https) - url.add("?name=orig") - - let uri = parseUri(url) - cond isTwitterUrl(uri) == true - - let code = await proxyMedia(request, url) - check code - get re"^\/pic\/(enc)?\/?(.+)": - var url = decoded(request, 1) - if "twimg.com" notin url: - url.insert(twimg) - if not url.startsWith(https): - url.insert(https) + let url = getPicUrl(request) + cond isTwitterUrl(parseUri(url)) == true + check await proxyMedia(request, url) - let uri = parseUri(url) - cond isTwitterUrl(uri) == true - - let code = await proxyMedia(request, url) - check code + get re"^\/pic\/orig\/(enc)?\/?(.+)": + let url = getPicUrl(request) + cond isTwitterUrl(parseUri(url)) == true + check await proxyMedia(request, url & "?name=orig") get re"^\/video\/(enc)?\/?(.+)\/(.+)$": let url = decoded(request, 2) @@ -123,8 +139,7 @@ proc createMediaRouter*(cfg: Config) = resp showError("Failed to verify signature", cfg) if ".mp4" in url or ".ts" in url or ".m4s" in url: - let code = await proxyMedia(request, url) - check code + check await proxyMedia(request, url) var content: string if ".vmap" in url: diff --git a/src/utils.nim b/src/utils.nim index 9002bbf..0b0f2a7 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import strutils, strformat, uri, tables, base64 import nimcrypto +import types var hmacKey: string @@ -28,6 +29,20 @@ proc setProxyEncoding*(state: bool) = proc getHmac*(data: string): string = ($hmac(sha256, hmacKey, data))[0 .. 12] +proc getBestMp4VidVariant(video: Video): VideoVariant = + for v in video.variants: + if v.bitrate >= result.bitrate: + result = v + +proc getVidVariant*(video: Video; playbackType: VideoType): VideoVariant = + case playbackType + of mp4: + return video.getBestMp4VidVariant + of m3u8, vmap: + for variant in video.variants: + if variant.contentType == playbackType: + return variant + proc getVidUrl*(link: string): string = if link.len == 0: return let sig = getHmac(link) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 5bee864..c0e95fd 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, sequtils, strformat, options, algorithm +import strutils, sequtils, strformat, options import karax/[karaxdsl, vdom, vstyles] from jester import Request @@ -90,7 +90,7 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = let container = if video.description.len == 0 and video.title.len == 0: "" else: " card-container" - playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4 + playbackType = if prefs.proxyVideos and video.hasMp4Url: mp4 else: video.playbackType buildHtml(tdiv(class="attachments card")): @@ -105,18 +105,15 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = renderVideoDisabled(playbackType, path) else: let - vars = video.variants.filterIt(it.contentType == playbackType) - vidUrl = vars.sortedByIt(it.resolution)[^1].url + vidUrl = video.getVidVariant(playbackType).url source = if prefs.proxyVideos: getVidUrl(vidUrl) else: vidUrl case playbackType of mp4: if prefs.muteVideos: - video(poster=thumb, controls="", muted=""): - source(src=source, `type`="video/mp4") + video(src=source, poster=thumb, controls="", muted="", preload="metadata"): else: - video(poster=thumb, controls=""): - source(src=source, `type`="video/mp4") + video(src=source, poster=thumb, controls="", preload="metadata"): of m3u8, vmap: video(poster=thumb, data-url=source, data-autoload="false") verbatim "
"