Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/parser.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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"}:
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/prefs_impl.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
128 changes: 73 additions & 55 deletions src/routes/media.nim
Original file line number Diff line number Diff line change
Expand Up @@ -12,64 +12,93 @@ 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()
try: result = await client.getContent(url)
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()

try:
let res = await client.get(url)
if res.status != "200 OK":
return Http404
hashed = $hash(url)

let hashed = $hash(url)
if request.headers.getOrDefault("If-None-Match") == hashed:
return Http304
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 c = newAsyncHttpClient(headers=newHttpHeaders({
"accept": "*/*",
"range": $req.headers.getOrDefault("range")
}))

let headers = newHttpHeaders({
"Content-Type": res.headers["content-type", 0],
"Content-Length": contentLength,
"Cache-Control": maxAge,
"ETag": hashed
})
try:
var res = await c.get(url)
if not res.status.startsWith("20"):
return Http404

respond(request, headers)
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")
}

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:
Expand All @@ -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)
Expand All @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions src/utils.nim
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, uri, tables, base64
import nimcrypto
import types

var
hmacKey: string
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/views/renderutils.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 16 additions & 15 deletions src/views/tweet.nim
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -12,7 +12,7 @@ const doctype = "<!DOCTYPE html>\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; prefs: Prefs): VNode =
buildHtml(tdiv):
Expand Down Expand Up @@ -85,34 +85,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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
playbackType = if prefs.proxyVideos and video.hasMp4Url: mp4
playbackType = if video.hasMp4Url: mp4

to keep non-proxied videos working

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't actually work every time. With the way Twitter serves mp4, a "cache miss" uses chunked transfer-encoding which results in 1 second playing and the video freezing until reload. That's what the retry logic is for in the media router, streaming the file without waiting for the cache is impossible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If proxying videos is disabled, your change would make it impossible to play videos at all, since browsers can't play m3u8 without hls.js (which is bound by CORS, so it only works if videos are proxied). I never experienced the transfer-encoding bug you mentioned, but IMO having it fail some of the time is better than having it fail all the time.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would hls.js not be there? I tested this thoroughly, the transfer-encoding bug actually happens quite often both in the browser and when fetched via a headless client, it simply doesn't work. If you go to to the search page and filter native video it's fairly easy to hit a cache miss.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would hls.js not be there?

it is, but it doesn't work when proxying is disabled (as far as i understand, it needs CORS headers to be sent from 3rd party domains (like video.twimg.com) in order for its requests to not get blocked by the browser).
but this is besides the point; I'd like to be able to see twitter videos even when javascript and proxying are disabled. and this works right now, and won't if this patch gets committed as-is.

I tested this thoroughly, the transfer-encoding bug actually happens quite often both in the browser and when fetched via a headless client, it simply doesn't work.
If you go to to the search page and filter native video it's fairly easy to hit a cache miss.

just tried that. these are the headers i get served from video files (not proxied): https://pastebin.com/yL485srS the first one is x-cache: MISS, the second one is x-cache: HIT. neither are sent using transfer-encoding:chunked and play fine. I went through multiple pages of /search?f=tweets&q=&f-native_video=on. I could reproduce this on my private instance (us-hosted) and nitter.net by disabling video proying and enabling mp4 playback with the same results.

I'm not saying you're wrong, but maybe we are talking about different things? maybe an alternative would be this:

Suggested change
playbackType = if prefs.proxyVideos and video.hasMp4Url: mp4
playbackType = if not prefs.hlsPlayback and video.hasMp4Url: mp4

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe their US caching works in a different way than in the EU, dunno, but I know for a fact I ran into this chunked encoding issue which broke video playback a lot of times even while proxying through Nitter. It happened when I was working on it in 2019, and it happened a week ago when I worked on this version. I'd be ok with some sort of "Always stream MP4" preference disabled by default, but serving non-proxied mp4 from Twitter is too broken.

Copy link
Contributor

@girst girst Jun 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be ok with some sort of "Always stream MP4" preference disabled by default,

that would be fine for my needs. thank you for considering that!

i also need to retract my earlier statement regarding hls.js requiring proxy: twitter actually sends the required cors-headers (access-control-allow-origin: *). i must have misremembered them not provididing those.

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 "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle")
verbatim "</div>"
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
Expand Down Expand Up @@ -145,7 +146,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"):
Expand Down