diff --git a/src/parser.nim b/src/parser.nim index c7d8bd1..af64ff5 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -84,8 +84,8 @@ proc parseVideo(js: JsonNode): Video = views: getVideoViewCount(js), available: true, 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 status, js{"ext_media_availability", "status"}: @@ -103,6 +103,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/prefs_impl.nim b/src/prefs_impl.nim index 8e2ac8f..b3d9c10 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)" diff --git a/src/routes/media.nim b/src/routes/media.nim index e63a0f8..02481a6 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -12,7 +12,8 @@ export httpclient, os, strutils, asyncstreams, base64, re const m3u8Mime* = "application/vnd.apple.mpegurl" - maxAge* = "max-age=604800" + mp4Mime* = "video/mp4" + maxAge* = "public, max-age=604800, must-revalidate" proc safeFetch*(url: string): Future[string] {.async.} = let client = newAsyncHttpClient() @@ -20,56 +21,84 @@ 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 + var headers = @{ + "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") + } - let contentLength = - if res.headers.hasKey("content-length"): - res.headers["content-length", 0] - else: - "" + 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 headers = newHttpHeaders({ - "Content-Type": res.headers["content-type", 0], - "Content-Length": contentLength, - "Cache-Control": maxAge, - "ETag": hashed - }) + let contentLength = res.getContentLength + if contentLength.len > 0: + headers.add ("content-length", contentLength) - respond(request, headers) + 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 +112,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 + let url = getPicUrl(request) + cond isTwitterUrl(parseUri(url)) == true + check await proxyMedia(request, url & "?name=orig") 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 uri = parseUri(url) - cond isTwitterUrl(uri) == true - - let code = await proxyMedia(request, url) - check code + let url = getPicUrl(request) + cond isTwitterUrl(parseUri(url)) == true + check await proxyMedia(request, url) get re"^\/video\/(enc)?\/?(.+)\/(.+)$": let url = decoded(request, 2) @@ -123,8 +142,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/renderutils.nim b/src/views/renderutils.nim index 9dffdcb..331d03e 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -84,7 +84,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 = if query.kind == tab: "tab-item active" diff --git a/src/views/tweet.nim b/src/views/tweet.nim index f47ae9a..f533e6b 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 @@ -12,7 +12,7 @@ const doctype = "\n" 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; pinned: bool; prefs: Prefs): VNode = buildHtml(tdiv): @@ -84,34 +84,35 @@ 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")): 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 - 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: - 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 "
" 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 @@ -144,7 +145,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"):