From 651941acd16c2414a824e3fd00641061cbca310f Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 6 Jun 2022 22:50:28 +0200 Subject: [PATCH 1/7] 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 "
" From 1dab9c9c61b052496235c8d437f65d3349f26b3b Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 6 Jun 2022 22:56:48 +0200 Subject: [PATCH 2/7] Update mp4Playback preference text --- src/prefs_impl.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 0223c82..b9734be 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -80,7 +80,7 @@ genPrefs: Media: mp4Playback(checkbox, true): - "Enable mp4 video playback (only for gifs)" + "Enable mp4 video playback" hlsPlayback(checkbox, false): "Enable hls video streaming (requires JavaScript)" From 0610f7b890bdee0e1d58a187eebdaa3e93446f05 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jun 2022 22:20:28 +0200 Subject: [PATCH 3/7] Fix image route order --- src/routes/media.nim | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/routes/media.nim b/src/routes/media.nim index 705da23..95a83d5 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -121,16 +121,16 @@ proc createMediaRouter*(cfg: Config) = get "/pic/?": resp Http404 - get re"^\/pic\/(enc)?\/?(.+)": - let url = getPicUrl(request) - cond isTwitterUrl(parseUri(url)) == true - check await proxyMedia(request, url) - get re"^\/pic\/orig\/(enc)?\/?(.+)": let url = getPicUrl(request) cond isTwitterUrl(parseUri(url)) == true check await proxyMedia(request, url & "?name=orig") + get re"^\/pic\/(enc)?\/?(.+)": + let url = getPicUrl(request) + cond isTwitterUrl(parseUri(url)) == true + check await proxyMedia(request, url) + get re"^\/video\/(enc)?\/?(.+)\/(.+)$": let url = decoded(request, 2) cond "http" in url From 608c3ca8dffcb4f3fc69fbf4ab9b81e662bed73e Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jun 2022 17:41:59 +0200 Subject: [PATCH 4/7] Lazy load images --- src/views/renderutils.nim | 2 +- src/views/tweet.nim | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index bab01cd..6d5f59b 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -82,7 +82,7 @@ proc genDate*(pref, state: string): VNode = proc genImg*(url: string; class=""): VNode = buildHtml(): - img(src=getPicUrl(url), class=class, alt="") + img(src=getPicUrl(url), class=class, alt="", loading="lazy", decoding="async") proc getTabClass*(query: Query; tab: QueryKind): string = result = "tab-item" diff --git a/src/views/tweet.nim b/src/views/tweet.nim index c0e95fd..c0accfc 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -16,7 +16,7 @@ proc getSmallPic(url: string): string = proc renderMiniAvatar(user: User; prefs: Prefs): VNode = let url = getPicUrl(user.getUserPic("_mini")) buildHtml(): - img(class=(prefs.getAvatarClass & " mini"), src=url) + img(class=(prefs.getAvatarClass & " mini"), src=url, loading="lazy") proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode = buildHtml(tdiv): @@ -97,13 +97,9 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = tdiv(class="gallery-video" & container): tdiv(class="attachment video-container"): let thumb = getSmallPic(video.thumb) - if not video.available: - img(src=thumb) - renderVideoUnavailable(video) - elif not prefs.isPlaybackEnabled(playbackType): - img(src=thumb) - renderVideoDisabled(playbackType, path) - else: + let canPlay = prefs.isPlaybackEnabled(playbackType) + + if video.available and canPlay: let vidUrl = video.getVidVariant(playbackType).url source = if prefs.proxyVideos: getVidUrl(vidUrl) @@ -119,6 +115,13 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = verbatim "
" tdiv(class="overlay-circle"): span(class="overlay-triangle") verbatim "
" + else: + img(src=thumb, loading="lazy", decoding="async") + if not canPlay: + renderVideoDisabled(playbackType, path) + else: + renderVideoUnavailable(video) + if container.len > 0: tdiv(class="card-content"): h2(class="card-title"): text video.title @@ -156,7 +159,7 @@ proc renderPoll(poll: Poll): VNode = proc renderCardImage(card: Card): VNode = buildHtml(tdiv(class="card-image-container")): tdiv(class="card-image"): - img(src=getPicUrl(card.image), alt="") + img(src=getPicUrl(card.image), alt="", loading="lazy") if card.kind == player: tdiv(class="card-overlay"): tdiv(class="overlay-circle"): From 00daab1f1504598587dc2d7f09e08f9b1449b7e0 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jun 2022 20:18:45 +0200 Subject: [PATCH 5/7] Disable mp4 preloading --- src/views/tweet.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index c0accfc..61b3b86 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -107,9 +107,9 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = case playbackType of mp4: if prefs.muteVideos: - video(src=source, poster=thumb, controls="", muted="", preload="metadata"): + video(src=source, poster=thumb, controls="", preload="none", muted=""): else: - video(src=source, poster=thumb, controls="", preload="metadata"): + video(src=source, poster=thumb, controls="", preload="none"): of m3u8, vmap: video(poster=thumb, data-url=source, data-autoload="false") verbatim "
" From 6e490c2dd93d767c49552750d9ae465616249561 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jun 2022 23:27:11 +0200 Subject: [PATCH 6/7] Improve gif html --- src/views/tweet.nim | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 61b3b86..bcc8f02 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -135,11 +135,9 @@ proc renderGif(gif: Gif; prefs: Prefs): VNode = let thumb = getSmallPic(gif.thumb) let url = getPicUrl(gif.url) if prefs.autoplayGifs: - video(class="gif", poster=thumb, controls="", autoplay="", muted="", loop=""): - source(src=url, `type`="video/mp4") + video(src=url, class="gif", poster=thumb, controls="", muted="", loop="", playsinline="", autoplay="") else: - video(class="gif", poster=thumb, controls="", muted="", loop=""): - source(src=url, `type`="video/mp4") + video(src=url, class="gif", poster=thumb, controls="", muted="", loop="", playsinline="") proc renderPoll(poll: Poll): VNode = buildHtml(tdiv(class="poll")): From 401aa264646642ca0fd1378b059cafa1ceb69aaa Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 21 May 2023 00:47:09 +0200 Subject: [PATCH 7/7] Improve proxied mp4 caching --- src/parser.nim | 7 +++++-- src/routes/media.nim | 15 +++++++++------ src/views/tweet.nim | 3 +-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 5ec21e4..76d3da3 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -84,8 +84,8 @@ proc parseVideo(js: JsonNode): Video = views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt), available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available", title: js{"ext_alt_text"}.getStr, - durationMs: js{"video_info", "duration_millis"}.getInt - # playbackType: mp4 + durationMs: js{"video_info", "duration_millis"}.getInt, + playbackType: m3u8 ) with title, js{"additional_media_info", "title"}: @@ -99,6 +99,9 @@ proc parseVideo(js: JsonNode): Video = contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary")) url = v{"url"}.getStr + if contentType == mp4: + result.playbackType = mp4 + result.variants.add VideoVariant( contentType: contentType, bitrate: v{"bitrate"}.getInt, diff --git a/src/routes/media.nim b/src/routes/media.nim index 95a83d5..02481a6 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -13,7 +13,7 @@ export httpclient, os, strutils, asyncstreams, base64, re const m3u8Mime* = "application/vnd.apple.mpegurl" mp4Mime* = "video/mp4" - maxAge* = "max-age=604800" + maxAge* = "public, max-age=604800, must-revalidate" proc safeFetch*(url: string): Future[string] {.async.} = let client = newAsyncHttpClient() @@ -61,9 +61,12 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = return Http404 var headers = @{ - "Accept-Ranges": "bytes", - "Content-Type": res.headers["content-type", 0], - "Cache-Control": maxAge + "accept-ranges": "bytes", + "content-type": $res.headers.getOrDefault("content-type"), + "cache-control": maxAge, + "age": $res.headers.getOrDefault("age"), + "date": $res.headers.getOrDefault("date"), + "last-modified": $res.headers.getOrDefault("last-modified") } var tries = 0 @@ -74,10 +77,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = let contentLength = res.getContentLength if contentLength.len > 0: - headers.add ("Content-Length", contentLength) + headers.add ("content-length", contentLength) if res.headers.hasKey("content-range"): - headers.add ("Content-Range", $res.headers.getOrDefault("content-range")) + headers.add ("content-range", $res.headers.getOrDefault("content-range")) respond(request, Http206, headers) else: respond(request, Http200, headers) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 70bbe20..7032ce9 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -101,8 +101,7 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = else: vidUrl case playbackType of mp4: - video(poster=thumb, controls="", muted=prefs.muteVideos): - source(src=source, `type`="video/mp4") + video(src=source, poster=thumb, controls="", muted=prefs.muteVideos, preload="metadata") of m3u8, vmap: video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) verbatim "
"