From 651941acd16c2414a824e3fd00641061cbca310f Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 6 Jun 2022 22:50:28 +0200 Subject: [PATCH 001/309] 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 002/309] 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 003/309] 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 004/309] 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 005/309] 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 006/309] 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 007/309] 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 "
" From f7e878c1260acc87fa53331bd5dc4584791baefd Mon Sep 17 00:00:00 2001 From: PrivacyDevel <105459436+PrivacyDevel@users.noreply.github.com> Date: Tue, 30 May 2023 11:37:35 +0000 Subject: [PATCH 008/309] fixed bug that caused threads on user profiles to be hidden (#885) --- src/parser.nim | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/parser.nim b/src/parser.nim index 5ec21e4..5b0d584 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -490,6 +490,10 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline = if not tweet.available: tweet.id = parseBiggestInt(entryId.getId()) result.content.add tweet + elif entryId.startsWith("profile-conversation") or entryId.startsWith("homeConversation"): + let (thread, self) = parseGraphThread(e) + for tweet in thread.content: + result.content.add tweet elif entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr From 38985af6ed30f050201b15425cdac0dc2e286b6d Mon Sep 17 00:00:00 2001 From: PrivacyDevel <105459436+PrivacyDevel@users.noreply.github.com> Date: Tue, 30 May 2023 21:42:14 +0000 Subject: [PATCH 009/309] fixed bug that caused everybody to be displayed as verified (#890) --- src/parser.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parser.nim b/src/parser.nim index 5b0d584..38dbb24 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -33,7 +33,7 @@ proc parseGraphUser(js: JsonNode): User = result = parseUser(user{"legacy"}) if "is_blue_verified" in user: - result.verified = true + result.verified = user{"is_blue_verified"}.getBool() proc parseGraphList*(js: JsonNode): List = if js.isNull: return From dcf73354ff173c0407c62f84ea3bb90a130303c1 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 1 Jul 2023 22:07:37 +0200 Subject: [PATCH 010/309] Fix GraphQL user crash with invalid JSON --- src/experimental/parser/graphql.nim | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 36014e3..0f08c4f 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -4,6 +4,9 @@ import user, ../types/[graphuser, graphlistmembers] from ../../types import User, Result, Query, QueryKind proc parseGraphUser*(json: string): User = + if json.len == 0 or json[0] != '{': + return + let raw = json.fromJson(GraphUser) if raw.data.user.result.reason.get("") == "Suspended": From 0bc3c153d9b38a3c02f321fb64a375fef6b97e8e Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Jul 2023 11:25:34 +0200 Subject: [PATCH 011/309] Fix everything (#927) * Switch bearer token and endpoints, update parser * Enable user search, disable tweet search * Disable multi-user timelines for now * Fix parsing of pinned tombstone --- src/api.nim | 26 ++++----- src/consts.nim | 67 +++++++++-------------- src/experimental/parser/graphql.nim | 8 +-- src/experimental/types/graphuser.nim | 4 +- src/parser.nim | 82 ++++++++++++++-------------- src/routes/embed.nim | 12 ++-- src/routes/rss.nim | 52 +++++++++--------- src/routes/search.nim | 14 +++-- src/routes/timeline.nim | 43 ++++++--------- src/sass/tweet/thread.scss | 26 +++++++++ src/tokens.nim | 9 ++- src/types.nim | 5 +- src/views/rss.nimf | 43 ++++++++------- src/views/search.nim | 2 +- src/views/timeline.nim | 48 ++++++++-------- src/views/tweet.nim | 18 +++--- tests/test_card.py | 35 +++--------- tests/test_profile.py | 4 +- tests/test_search.py | 10 ++-- tests/test_tweet.py | 16 +++--- 20 files changed, 260 insertions(+), 264 deletions(-) diff --git a/src/api.nim b/src/api.nim index b23aa87..d99eb3d 100644 --- a/src/api.nim +++ b/src/api.nim @@ -7,20 +7,20 @@ import experimental/parser as newParser proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return let - variables = %*{"screen_name": username} - params = {"variables": $variables, "features": gqlFeatures} + variables = """{"screen_name": "$1"}""" % username + params = {"variables": variables, "features": gqlFeatures} js = await fetchRaw(graphUser ? params, Api.userScreenName) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = if id.len == 0 or id.any(c => not c.isDigit): return let - variables = %*{"userId": id} - params = {"variables": $variables, "features": gqlFeatures} + variables = """{"rest_id": "$1"}""" % id + params = {"variables": variables, "features": gqlFeatures} js = await fetchRaw(graphUserById ? params, Api.userRestId) result = parseGraphUser(js) -proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timeline] {.async.} = +proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" @@ -40,7 +40,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = variables = listTweetsVariables % [id, cursor] params = {"variables": variables, "features": gqlFeatures} js = await fetch(graphListTweets ? params, Api.listTweets) - result = parseGraphTimeline(js, "list", after) + result = parseGraphTimeline(js, "list", after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let @@ -50,8 +50,8 @@ proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} = let - variables = %*{"listId": id} - params = {"variables": $variables, "features": gqlFeatures} + variables = """{"listId": "$1"}""" % id + params = {"variables": variables, "features": gqlFeatures} result = parseGraphList(await fetch(graphListById ? params, Api.list)) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = @@ -72,7 +72,7 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let - variables = tweetResultVariables % id + variables = """{"rest_id": "$1"}""" % id params = {"variables": variables, "features": gqlFeatures} js = await fetch(graphTweetResult ? params, Api.tweetResult) result = parseGraphTweetResult(js) @@ -95,10 +95,10 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = if after.len > 0: result.replies = await getReplies(id, after) -proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} = +proc getGraphSearch*(query: Query; after=""): Future[Profile] {.async.} = let q = genQueryParam(query) if q.len == 0 or q == emptyQuery: - return Result[Tweet](query: query, beginning: true) + return Profile(tweets: Timeline(query: query, beginning: true)) var variables = %*{ @@ -112,8 +112,8 @@ proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} = if after.len > 0: variables["cursor"] = % after let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} - result = parseGraphSearch(await fetch(url, Api.search), after) - result.query = query + result = Profile(tweets: parseGraphSearch(await fetch(url, Api.search), after)) + result.tweets.query = query proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = if query.text.len == 0: diff --git a/src/consts.nim b/src/consts.nim index f22581f..184f9da 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -2,7 +2,7 @@ import uri, sequtils, strutils const - auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" + auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" api = parseUri("https://api.twitter.com") activate* = $(api / "1.1/guest/activate.json") @@ -11,18 +11,18 @@ const userSearch* = api / "1.1/users/search.json" graphql = api / "graphql" - graphUser* = graphql / "pVrmNaXcxPjisIvKtLDMEA/UserByScreenName" - graphUserById* = graphql / "1YAM811Q8Ry4XyPpJclURQ/UserByRestId" - graphUserTweets* = graphql / "WzJjibAcDa-oCjCcLOotcg/UserTweets" - graphUserTweetsAndReplies* = graphql / "fn9oRltM1N4thkh5CVusPg/UserTweetsAndReplies" - graphUserMedia* = graphql / "qQoeS7szGavsi8-ehD2AWg/UserMedia" - graphTweet* = graphql / "miKSMGb2R1SewIJv2-ablQ/TweetDetail" - graphTweetResult* = graphql / "0kc0a_7TTr3dvweZlMslsQ/TweetResultByRestId" + graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" + graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" + graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2" + graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2" + graphUserMedia* = graphql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" + graphTweet* = graphql / "83h5UyHZ9wEKBVzALX8R_g/ConversationTimelineV2" + graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" - graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline" + graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" timelineParams* = { "include_profile_interstitial_type": "0", @@ -49,10 +49,13 @@ const }.toSeq gqlFeatures* = """{ + "android_graphql_skip_api_media_color_palette": false, "blue_business_profile_image_shape_enabled": false, + "creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": false, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, + "hidden_profile_likes_enabled": false, "highlights_tweets_tab_ui_enabled": false, "interactive_text_enabled": false, "longform_notetweets_consumption_enabled": true, @@ -64,15 +67,25 @@ const "responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_timeline_navigation_enabled": false, + "responsive_web_media_download_video_enabled": false, "responsive_web_text_conversations_enabled": false, + "responsive_web_twitter_article_tweet_consumption_enabled": false, "responsive_web_twitter_blue_verified_badge_is_enabled": true, "rweb_lists_timeline_redesign_enabled": true, "spaces_2022_h2_clipping": true, "spaces_2022_h2_spaces_communities": true, "standardized_nudges_misinfo": false, + "subscriptions_verification_info_enabled": true, + "subscriptions_verification_info_reason_enabled": true, + "subscriptions_verification_info_verified_since_enabled": true, + "super_follow_badge_privacy_enabled": false, + "super_follow_exclusive_tweet_notifications_enabled": false, + "super_follow_tweet_api_enabled": false, + "super_follow_user_api_enabled": false, "tweet_awards_web_tipping_enabled": false, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, "tweetypie_unmention_optimization_enabled": false, + "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, "verified_phone_label_enabled": false, "vibe_api_enabled": false, "view_counts_everywhere_api_enabled": false @@ -81,41 +94,15 @@ const tweetVariables* = """{ "focalTweetId": "$1", $2 - "withBirdwatchNotes": false, - "includePromotedContent": false, - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false, - "withVoice": false -}""" - - tweetResultVariables* = """{ - "tweetId": "$1", - "includePromotedContent": false, - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false, - "withVoice": false, - "withCommunity": false + "includeHasBirdwatchNotes": false }""" userTweetsVariables* = """{ - "userId": "$1", $2 - "count": 20, - "includePromotedContent": false, - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false, - "withVoice": false, - "withV2Timeline": true + "rest_id": "$1", $2 + "count": 20 }""" listTweetsVariables* = """{ - "listId": "$1", $2 - "count": 20, - "includePromotedContent": false, - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false, - "withVoice": false + "rest_id": "$1", $2 + "count": 20 }""" diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 0f08c4f..b9da7c4 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -9,12 +9,12 @@ proc parseGraphUser*(json: string): User = let raw = json.fromJson(GraphUser) - if raw.data.user.result.reason.get("") == "Suspended": + if raw.data.userResult.result.unavailableReason.get("") == "Suspended": return User(suspended: true) - result = toUser raw.data.user.result.legacy - result.id = raw.data.user.result.restId - result.verified = result.verified or raw.data.user.result.isBlueVerified + result = toUser raw.data.userResult.result.legacy + result.id = raw.data.userResult.result.restId + result.verified = result.verified or raw.data.userResult.result.isBlueVerified proc parseGraphListMembers*(json, cursor: string): Result[User] = result = Result[User]( diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index 478e7f3..c30eed9 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -3,7 +3,7 @@ import user type GraphUser* = object - data*: tuple[user: UserData] + data*: tuple[userResult: UserData] UserData* = object result*: UserResult @@ -12,4 +12,4 @@ type legacy*: RawUser restId*: string isBlueVerified*: bool - reason*: Option[string] + unavailableReason*: Option[string] diff --git a/src/parser.nim b/src/parser.nim index 38dbb24..7b178f3 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -29,7 +29,7 @@ proc parseUser(js: JsonNode; id=""): User = result.expandUserEntities(js) proc parseGraphUser(js: JsonNode): User = - let user = ? js{"user_results", "result"} + let user = ? js{"user_result", "result"} result = parseUser(user{"legacy"}) if "is_blue_verified" in user: @@ -262,6 +262,11 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.gif = some(parseGif(m)) else: discard + with url, m{"url"}: + if result.text.endsWith(url.getStr): + result.text.removeSuffix(url.getStr) + result.text = result.text.strip() + with jsWithheld, js{"withheld_in_countries"}: let withheldInCountries: seq[string] = if jsWithheld.kind != JArray: @[] @@ -294,16 +299,6 @@ proc finalizeTweet(global: GlobalObjects; id: string): Tweet = else: result.retweet = some Tweet() -proc parsePin(js: JsonNode; global: GlobalObjects): Tweet = - let pin = js{"pinEntry", "entry", "entryId"}.getStr - if pin.len == 0: return - - let id = pin.getId - if id notin global.tweets: return - - global.tweets[id].pinned = true - return finalizeTweet(global, id) - proc parseGlobalObjects(js: JsonNode): GlobalObjects = result = GlobalObjects() let @@ -314,7 +309,7 @@ proc parseGlobalObjects(js: JsonNode): GlobalObjects = result.users[k] = parseUser(v, k) for k, v in tweets: - var tweet = parseTweet(v, v{"card"}) + var tweet = parseTweet(v, v{"tweet_card"}) if tweet.user.id in result.users: tweet.user = result.users[tweet.user.id] result.tweets[k] = tweet @@ -324,11 +319,6 @@ proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNod return for i in js: - when T is Tweet: - if res.beginning and i{"pinEntry"}.notNull: - with pin, parsePin(i, global): - res.content.add pin - with r, i{"replaceEntry", "entry"}: if "top" in r{"entryId"}.getStr: res.top = r.getCursor @@ -369,7 +359,7 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline = proc parsePhotoRail*(js: JsonNode): PhotoRail = for tweet in js: let - t = parseTweet(tweet, js{"card"}) + t = parseTweet(tweet, js{"tweet_card"}) url = if t.photos.len > 0: t.photos[0] elif t.video.isSome: get(t.video).thumb elif t.gif.isSome: get(t.gif).thumb @@ -387,13 +377,17 @@ proc parseGraphTweet(js: JsonNode): Tweet = of "TweetUnavailable": return Tweet() of "TweetTombstone": - return Tweet(text: js{"tombstone", "text"}.getTombstone) + with text, js{"tombstone", "richText"}: + return Tweet(text: text.getTombstone) + with text, js{"tombstone", "text"}: + return Tweet(text: text.getTombstone) + return Tweet() of "TweetPreviewDisplay": return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") of "TweetWithVisibilityResults": return parseGraphTweet(js{"tweet"}) - var jsCard = copy(js{"card", "legacy"}) + var jsCard = copy(js{"tweet_card", "legacy"}) if jsCard.kind != JNull: var values = newJObject() for val in jsCard["binding_values"]: @@ -401,6 +395,7 @@ proc parseGraphTweet(js: JsonNode): Tweet = jsCard["binding_values"] = values result = parseTweet(js{"legacy"}, jsCard) + result.id = js{"rest_id"}.getId result.user = parseGraphUser(js{"core"}) with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: @@ -414,32 +409,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = for t in js{"content", "items"}: let entryId = t{"entryId"}.getStr if "cursor-showmore" in entryId: - let cursor = t{"item", "itemContent", "value"} + let cursor = t{"item", "content", "value"} result.thread.cursor = cursor.getStr result.thread.hasMore = true elif "tweet" in entryId: - let tweet = parseGraphTweet(t{"item", "itemContent", "tweet_results", "result"}) + let tweet = parseGraphTweet(t{"item", "content", "tweetResult", "result"}) result.thread.content.add tweet - if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread": + if t{"item", "content", "tweetDisplayType"}.getStr == "SelfThread": result.self = true proc parseGraphTweetResult*(js: JsonNode): Tweet = - with tweet, js{"data", "tweetResult", "result"}: + with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet) proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) - let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"} + let instructions = ? js{"data", "timeline_response", "instructions"} if instructions.len == 0: return for e in instructions[0]{"entries"}: let entryId = e{"entryId"}.getStr - # echo entryId if entryId.startsWith("tweet"): - with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + with tweetResult, e{"content", "content", "tweetResult", "result"}: let tweet = parseGraphTweet(tweetResult) if not tweet.available: @@ -454,7 +448,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = let tweet = Tweet( id: parseBiggestInt(id), available: false, - text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone + text: e{"content", "content", "tombstoneInfo", "richText"}.getTombstone ) if id == tweetId: @@ -468,34 +462,42 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = else: result.replies.content.add thread elif entryId.startsWith("cursor-bottom"): - result.replies.bottom = e{"content", "itemContent", "value"}.getStr + result.replies.bottom = e{"content", "content", "value"}.getStr -proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline = - result = Timeline(beginning: after.len == 0) +proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = + result = Profile(tweets: Timeline(beginning: after.len == 0)) let instructions = - if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"} - else: ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"} + if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"} + else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} if instructions.len == 0: return for i in instructions: - if i{"type"}.getStr == "TimelineAddEntries": + if i{"__typename"}.getStr == "TimelineAddEntries": for e in i{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): - with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + with tweetResult, e{"content", "content", "tweetResult", "result"}: let tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = parseBiggestInt(entryId.getId()) - result.content.add tweet - elif entryId.startsWith("profile-conversation") or entryId.startsWith("homeConversation"): + result.tweets.content.add tweet + elif "-conversation-" in entryId or entryId.startsWith("homeConversation"): let (thread, self) = parseGraphThread(e) - for tweet in thread.content: - result.content.add tweet + result.tweets.content.add thread elif entryId.startsWith("cursor-bottom"): - result.bottom = e{"content", "value"}.getStr + result.tweets.bottom = e{"content", "value"}.getStr + if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry": + with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}: + let tweet = parseGraphTweet(tweetResult) + tweet.pinned = true + if not tweet.available and tweet.tombstone.len == 0: + let entryId = i{"entry", "entryId"}.getEntryId + if entryId.len > 0: + tweet.id = parseBiggestInt(entryId) + result.pinned = some tweet proc parseGraphSearch*(js: JsonNode; after=""): Timeline = result = Timeline(beginning: after.len == 0) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index baaec68..994364b 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -10,22 +10,22 @@ export api, embed, vdom, tweet, general, router_utils proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": - let convo = await getTweet(@"id") - if convo == nil or convo.tweet == nil or convo.tweet.video.isNone: + let tweet = await getGraphTweetResult(@"id") + if tweet == nil or tweet.video.isNone: resp Http404 - resp renderVideoEmbed(convo.tweet, cfg, request) + resp renderVideoEmbed(tweet, cfg, request) get "/@user/status/@id/embed": let - convo = await getTweet(@"id") + tweet = await getGraphTweetResult(@"id") prefs = cookiePrefs() path = getPath() - if convo == nil or convo.tweet == nil: + if tweet == nil: resp Http404 - resp renderTweetEmbed(convo.tweet, path, prefs, cfg, request) + resp renderTweetEmbed(tweet, path, prefs, cfg, request) get "/embed/Tweet.html": let id = @"id" diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 1323ed3..8eec399 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -27,14 +27,12 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. else: var q = query q.fromUser = names - profile = Profile( - tweets: await getGraphSearch(q, after), - # this is kinda dumb - user: User( - username: name, - fullname: names.join(" | "), - userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png" - ) + profile = await getGraphSearch(q, after) + # this is kinda dumb + profile.user = User( + username: name, + fullname: names.join(" | "), + userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png" ) if profile.user.suspended: @@ -61,29 +59,29 @@ template respRss*(rss, page) = proc createRssRouter*(cfg: Config) = router rss: - get "/search/rss": - cond cfg.enableRss - if @"q".len > 200: - resp Http400, showError("Search input too long.", cfg) + # get "/search/rss": + # cond cfg.enableRss + # if @"q".len > 200: + # resp Http400, showError("Search input too long.", cfg) - let query = initQuery(params(request)) - if query.kind != tweets: - resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) + # let query = initQuery(params(request)) + # if query.kind != tweets: + # resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) - let - cursor = getCursor() - key = redisKey("search", $hash(genQueryUrl(query)), cursor) + # let + # cursor = getCursor() + # key = redisKey("search", $hash(genQueryUrl(query)), cursor) - var rss = await getCachedRss(key) - if rss.cursor.len > 0: - respRss(rss, "Search") + # var rss = await getCachedRss(key) + # if rss.cursor.len > 0: + # respRss(rss, "Search") - let tweets = await getGraphSearch(query, cursor) - rss.cursor = tweets.bottom - rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) + # let tweets = await getGraphSearch(query, cursor) + # rss.cursor = tweets.bottom + # rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) - await cacheRss(key, rss) - respRss(rss, "Search") + # await cacheRss(key, rss) + # respRss(rss, "Search") get "/@name/rss": cond cfg.enableRss @@ -112,7 +110,7 @@ proc createRssRouter*(cfg: Config) = case tab of "with_replies": getReplyQuery(name) of "media": getMediaQuery(name) - of "search": initQuery(params(request), name=name) + # of "search": initQuery(params(request), name=name) else: Query(fromUser: @[name]) let searchKey = if tab != "search": "" diff --git a/src/routes/search.nim b/src/routes/search.nim index 02c14e3..ed2c397 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -34,11 +34,15 @@ proc createSearchRouter*(cfg: Config) = users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) of tweets: - let - tweets = await getGraphSearch(query, getCursor()) - rss = "/search/rss?" & genQueryUrl(query) - resp renderMain(renderTweetSearch(tweets, prefs, getPath()), - request, cfg, prefs, title, rss=rss) + # let + # tweets = await getGraphSearch(query, getCursor()) + # rss = "/search/rss?" & genQueryUrl(query) + # resp renderMain(renderTweetSearch(tweets, prefs, getPath()), + # request, cfg, prefs, title, rss=rss) + var fakeTimeline = Timeline(beginning: true) + fakeTimeline.content.add Tweet(tombstone: "Tweet search is unavailable for now") + + resp renderMain(renderTweetSearch(fakeTimeline, prefs, getPath()), request, cfg, prefs, title) else: resp Http404, showError("Invalid search", cfg) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 331b8ae..4ac60d2 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -45,34 +45,24 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; after.setLen 0 let - timeline = - case query.kind - of posts: getGraphUserTweets(userId, TimelineKind.tweets, after) - of replies: getGraphUserTweets(userId, TimelineKind.replies, after) - of media: getGraphUserTweets(userId, TimelineKind.media, after) - else: getGraphSearch(query, after) - rail = skipIf(skipRail or query.kind == media, @[]): getCachedPhotoRail(name) - user = await getCachedUser(name) + user = getCachedUser(name) - var pinned: Option[Tweet] - if not skipPinned and user.pinnedTweet > 0 and - after.len == 0 and query.kind in {posts, replies}: - let tweet = await getCachedTweet(user.pinnedTweet) - if not tweet.isNil: - tweet.pinned = true - tweet.user = user - pinned = some tweet + result = + case query.kind + of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) + of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) + of media: await getGraphUserTweets(userId, TimelineKind.media, after) + else: Profile(tweets: Timeline(beginning: true, content: @[Chain(content: + @[Tweet(tombstone: "Tweet search is unavailable for now")] + )])) + # else: await getGraphSearch(query, after) - result = Profile( - user: user, - pinned: pinned, - tweets: await timeline, - photoRail: await rail - ) + result.user = await user + result.photoRail = await rail if result.user.protected or result.user.suspended: return @@ -83,8 +73,11 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = if query.fromUser.len != 1: let - timeline = await getGraphSearch(query, after) - html = renderTweetSearch(timeline, prefs, getPath()) + # timeline = await getGraphSearch(query, after) + timeline = Profile(tweets: Timeline(beginning: true, content: @[Chain(content: + @[Tweet(tombstone: "This features is unavailable for now")] + )])) + html = renderTweetSearch(timeline.tweets, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins) @@ -138,7 +131,7 @@ proc createTimelineRouter*(cfg: Config) = # used for the infinite scroll feature if @"scroll".len > 0: if query.fromUser.len != 1: - var timeline = await getGraphSearch(query, after) + var timeline = (await getGraphSearch(query, after)).tweets if timeline.content.len == 0: resp Http404 timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 5fbad21..19fb3e0 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -110,3 +110,29 @@ margin-left: 58px; padding: 7px 0; } + +.timeline-item.thread.more-replies-thread { + padding: 0 0.75em; + + &::before { + top: 40px; + margin-bottom: 31px; + } + + .more-replies { + display: flex; + padding-top: unset !important; + margin-top: 8px; + + &::before { + display: inline-block; + position: relative; + top: -1px; + line-height: 0.4em; + } + + .more-replies-text { + display: inline; + } + } +} diff --git a/src/tokens.nim b/src/tokens.nim index 6ef81f5..6643de3 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -41,11 +41,10 @@ proc getPoolJson*(): JsonNode = let maxReqs = case api - of Api.timeline: 187 - of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets, - Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, - Api.userRestId, Api.userScreenName, - Api.tweetDetail, Api.tweetResult, Api.search: 500 + of Api.timeline: 180 + of Api.userTweets, Api.userTweetsAndReplies, Api.userRestId, + Api.userScreenName, Api.tweetDetail, Api.tweetResult, Api.search: 500 + of Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia: 500 of Api.userSearch: 900 reqs = maxReqs - token.apis[api].remaining diff --git a/src/types.nim b/src/types.nim index 4dca5f0..e7f3303 100644 --- a/src/types.nim +++ b/src/types.nim @@ -222,7 +222,7 @@ type after*: Chain replies*: Result[Chain] - Timeline* = Result[Tweet] + Timeline* = Result[Chain] Profile* = object user*: User @@ -274,3 +274,6 @@ type proc contains*(thread: Chain; tweet: Tweet): bool = thread.content.anyIt(it.id == tweet.id) + +proc add*(timeline: var seq[Chain]; tweet: Tweet) = + timeline.add Chain(content: @[tweet]) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 96f6466..ce2518a 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -56,24 +56,29 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #end if #end proc # -#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string = +#proc renderRssTweets(tweets: seq[Chain]; cfg: Config; userId=""): string = #let urlPrefix = getUrlPrefix(cfg) #var links: seq[string] -#for t in tweets: -# let retweet = if t.retweet.isSome: t.user.username else: "" -# let tweet = if retweet.len > 0: t.retweet.get else: t -# let link = getLink(tweet) -# if link in links: continue -# end if -# links.add link - - ${getTitle(tweet, retweet)} - @${tweet.user.username} - - ${getRfc822Time(tweet)} - ${urlPrefix & link} - ${urlPrefix & link} - +#for c in tweets: +# for t in c.content: +# if userId.len > 0 and t.user.id != userId: continue +# end if +# +# let retweet = if t.retweet.isSome: t.user.username else: "" +# let tweet = if retweet.len > 0: t.retweet.get else: t +# let link = getLink(tweet) +# if link in links: continue +# end if +# links.add link + + ${getTitle(tweet, retweet)} + @${tweet.user.username} + + ${getRfc822Time(tweet)} + ${urlPrefix & link} + ${urlPrefix & link} + +# end for #end for #end proc # @@ -102,13 +107,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} 128 #if profile.tweets.content.len > 0: -${renderRssTweets(profile.tweets.content, cfg)} +${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)} #end if #end proc # -#proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string = +#proc renderListRss*(tweets: seq[Chain]; list: List; cfg: Config): string = #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}" #result = "" @@ -125,7 +130,7 @@ ${renderRssTweets(tweets, cfg)} #end proc # -#proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string = +#proc renderSearchRss*(tweets: seq[Chain]; name, param: string; cfg: Config): string = #let link = &"{getUrlPrefix(cfg)}/search" #let escName = xmltree.escape(name) #result = "" diff --git a/src/views/search.nim b/src/views/search.nim index 72c59f5..401e6da 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -88,7 +88,7 @@ proc renderSearchPanel*(query: Query): VNode = span(class="search-title"): text "Near" genInput("near", "", query.near, "Location...", autofocus=false) -proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string; +proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = let query = results.query buildHtml(tdiv(class="timeline-container")): diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 54cad7a..8ae888e 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, sequtils, algorithm, uri, options +import strutils, strformat, algorithm, uri, options import karax/[karaxdsl, vdom] import ".."/[types, query, formatters] @@ -43,20 +43,18 @@ proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="thread-line")): let sortedThread = thread.sortedByIt(it.id) for i, tweet in sortedThread: + # thread has a gap, display "more replies" link + if i > 0 and tweet.replyId != sortedThread[i - 1].id: + tdiv(class="timeline-item thread more-replies-thread"): + tdiv(class="more-replies"): + a(class="more-replies-text", href=getLink(tweet)): + text "more replies" + let show = i == thread.high and sortedThread[0].id != tweet.threadId let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" renderTweet(tweet, prefs, path, class=(header & "thread"), index=i, last=(i == thread.high), showThread=show) -proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] = - result = @[it] - if it.retweet.isSome or it.replyId in threads: return - for t in tweets: - if t.id == result[0].replyId: - result.insert t - elif t.replyId == result[0].id: - result.add t - proc renderUser(user: User; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-item")): a(class="tweet-link", href=("/" & user.username)) @@ -89,7 +87,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = else: renderNoMore() -proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string; +proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = buildHtml(tdiv(class="timeline")): if not results.beginning: @@ -105,26 +103,26 @@ proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string; else: renderNoneFound() else: - var - threads: seq[int64] - retweets: seq[int64] + var retweets: seq[int64] - for tweet in results.content: - let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 + for thread in results.content: + if thread.content.len == 1: + let + tweet = thread.content[0] + retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 - if tweet.id in threads or rt in retweets or tweet.id in retweets or - tweet.pinned and prefs.hidePins: continue + if retweetId in retweets or tweet.id in retweets or + tweet.pinned and prefs.hidePins: + continue - let thread = results.content.threadFilter(threads, tweet) - if thread.len < 2: var hasThread = tweet.hasThread - if rt != 0: - retweets &= rt + if retweetId != 0 and tweet.retweet.isSome: + retweets &= retweetId hasThread = get(tweet.retweet).hasThread renderTweet(tweet, prefs, path, showThread=hasThread) else: - renderThread(thread, prefs, path) - threads &= thread.mapIt(it.id) + renderThread(thread.content, prefs, path) - renderMore(results.query, results.bottom) + if results.bottom.len > 0: + renderMore(results.query, results.bottom) renderToTop() diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 3338b71..f47ae9a 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -14,15 +14,14 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode = buildHtml(): img(class=(prefs.getAvatarClass & " mini"), src=url) -proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode = +proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode = buildHtml(tdiv): - if retweet.len > 0: - tdiv(class="retweet-header"): - span: icon "retweet", retweet & " retweeted" - - if tweet.pinned: + if pinned: tdiv(class="pinned"): span: icon "pin", "Pinned Tweet" + elif retweet.len > 0: + tdiv(class="retweet-header"): + span: icon "retweet", retweet & " retweeted" tdiv(class="tweet-header"): a(class="tweet-avatar", href=("/" & tweet.user.username)): @@ -290,7 +289,10 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.quote.isSome: renderQuote(tweet.quote.get(), prefs, path) - let fullTweet = tweet + let + fullTweet = tweet + pinned = tweet.pinned + var retweet: string var tweet = fullTweet if tweet.retweet.isSome: @@ -303,7 +305,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; tdiv(class="tweet-body"): var views = "" - renderHeader(tweet, retweet, prefs) + renderHeader(tweet, retweet, pinned, prefs) if not afterTweet and index == 0 and tweet.reply.len > 0 and (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username): diff --git a/tests/test_card.py b/tests/test_card.py index 696b9d5..f84ddca 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -16,7 +16,12 @@ card = [ ['FluentAI/status/1116417904831029248', 'Amazon’s Alexa isn’t just AI — thousands of humans are listening', 'One of the only ways to improve Alexa is to have human beings check it for errors', - 'theverge.com', True] + 'theverge.com', True], + + ['nim_lang/status/1082989146040340480', + 'Nim in 2018: A short recap', + 'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.', + 'nim-lang.org', True] ] no_thumb = [ @@ -33,12 +38,7 @@ no_thumb = [ ['voidtarget/status/1133028231672582145', 'sinkingsugar/nimqt-example', 'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.', - 'github.com'], - - ['nim_lang/status/1082989146040340480', - 'Nim in 2018: A short recap', - 'Posted by u/miran1 - 36 votes and 46 comments', - 'reddit.com'] + 'github.com'] ] playable = [ @@ -53,17 +53,6 @@ playable = [ 'youtube.com'] ] -# promo = [ - # ['BangOlufsen/status/1145698701517754368', - # 'Upgrade your journey', '', - # 'www.bang-olufsen.com'], - - # ['BangOlufsen/status/1154934429900406784', - # 'Learn more about Beosound Shape', '', - # 'www.bang-olufsen.com'] -# ] - - class CardTest(BaseTestCase): @parameterized.expand(card) def test_card(self, tweet, title, description, destination, large): @@ -98,13 +87,3 @@ class CardTest(BaseTestCase): self.assert_element_visible('.card-overlay') if len(description) > 0: self.assert_text(description, c.description) - - # @parameterized.expand(promo) - # def test_card_promo(self, tweet, title, description, destination): - # self.open_nitter(tweet) - # c = Card(Conversation.main + " ") - # self.assert_text(title, c.title) - # self.assert_text(destination, c.destination) - # self.assert_element_visible('.video-overlay') - # if len(description) > 0: - # self.assert_text(description, c.description) diff --git a/tests/test_profile.py b/tests/test_profile.py index f9b5047..4c75ad2 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -66,8 +66,8 @@ class ProfileTest(BaseTestCase): self.assert_text(f'User "{username}" not found') def test_suspended(self): - self.open_nitter('user') - self.assert_text('User "user" has been suspended') + self.open_nitter('suspendme') + self.assert_text('User "suspendme" has been suspended') @parameterized.expand(banner_image) def test_banner_image(self, username, url): diff --git a/tests/test_search.py b/tests/test_search.py index 80ee36a..62c4640 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -2,8 +2,8 @@ from base import BaseTestCase from parameterized import parameterized -class SearchTest(BaseTestCase): - @parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) - def test_username_search(self, username): - self.search_username(username) - self.assert_text(f'{username}') +#class SearchTest(BaseTestCase): + #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) + #def test_username_search(self, username): + #self.search_username(username) + #self.assert_text(f'{username}') diff --git a/tests/test_tweet.py b/tests/test_tweet.py index 9209e70..e4231a4 100644 --- a/tests/test_tweet.py +++ b/tests/test_tweet.py @@ -74,9 +74,9 @@ retweet = [ [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr'] ] -reply = [ - ['mobile_test/with_replies', 15] -] +# reply = [ +# ['mobile_test/with_replies', 15] +# ] class TweetTest(BaseTestCase): @@ -137,8 +137,8 @@ class TweetTest(BaseTestCase): self.open_nitter(tweet) self.assert_text('Tweet not found', '.error-panel') - @parameterized.expand(reply) - def test_thread(self, tweet, num): - self.open_nitter(tweet) - thread = self.find_element(f'.timeline > div:nth-child({num})') - self.assertIn(thread.get_attribute('class'), 'thread-line') + # @parameterized.expand(reply) + # def test_thread(self, tweet, num): + # self.open_nitter(tweet) + # thread = self.find_element(f'.timeline > div:nth-child({num})') + # self.assertIn(thread.get_attribute('class'), 'thread-line') From b290f6fd29ac20717bad359fc55d32822d3e054d Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 12 Jul 2023 01:34:39 +0200 Subject: [PATCH 012/309] Optimize timeline data structure --- src/parser.nim | 2 +- src/routes/timeline.nim | 8 ++------ src/types.nim | 10 ++++++---- src/views/rss.nimf | 16 ++++++++-------- src/views/timeline.nim | 8 ++++---- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 7b178f3..5dc96df 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -486,7 +486,7 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = result.tweets.content.add tweet elif "-conversation-" in entryId or entryId.startsWith("homeConversation"): let (thread, self) = parseGraphThread(e) - result.tweets.content.add thread + result.tweets.content.add thread.content elif entryId.startsWith("cursor-bottom"): result.tweets.bottom = e{"content", "value"}.getStr if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry": diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 4ac60d2..e62c9e0 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -56,9 +56,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) - else: Profile(tweets: Timeline(beginning: true, content: @[Chain(content: - @[Tweet(tombstone: "Tweet search is unavailable for now")] - )])) + else: Profile(tweets: Timeline(beginning: true, content: @[@[Tweet(tombstone: "Tweet search is unavailable for now")]])) # else: await getGraphSearch(query, after) result.user = await user @@ -74,9 +72,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; if query.fromUser.len != 1: let # timeline = await getGraphSearch(query, after) - timeline = Profile(tweets: Timeline(beginning: true, content: @[Chain(content: - @[Tweet(tombstone: "This features is unavailable for now")] - )])) + timeline = Profile(tweets: Timeline(beginning: true, content: @[@[Tweet(tombstone: "This features is unavailable for now")]])) html = renderTweetSearch(timeline.tweets, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) diff --git a/src/types.nim b/src/types.nim index e7f3303..f7d5f6b 100644 --- a/src/types.nim +++ b/src/types.nim @@ -205,6 +205,8 @@ type video*: Option[Video] photos*: seq[string] + Tweets* = seq[Tweet] + Result*[T] = object content*: seq[T] top*, bottom*: string @@ -212,7 +214,7 @@ type query*: Query Chain* = object - content*: seq[Tweet] + content*: Tweets hasMore*: bool cursor*: string @@ -222,7 +224,7 @@ type after*: Chain replies*: Result[Chain] - Timeline* = Result[Chain] + Timeline* = Result[Tweets] Profile* = object user*: User @@ -275,5 +277,5 @@ type proc contains*(thread: Chain; tweet: Tweet): bool = thread.content.anyIt(it.id == tweet.id) -proc add*(timeline: var seq[Chain]; tweet: Tweet) = - timeline.add Chain(content: @[tweet]) +proc add*(timeline: var seq[Tweets]; tweet: Tweet) = + timeline.add @[tweet] diff --git a/src/views/rss.nimf b/src/views/rss.nimf index ce2518a..036a7b9 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -56,16 +56,16 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #end if #end proc # -#proc renderRssTweets(tweets: seq[Chain]; cfg: Config; userId=""): string = +#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = #let urlPrefix = getUrlPrefix(cfg) #var links: seq[string] -#for c in tweets: -# for t in c.content: -# if userId.len > 0 and t.user.id != userId: continue +#for thread in tweets: +# for tweet in thread: +# if userId.len > 0 and tweet.user.id != userId: continue # end if # -# let retweet = if t.retweet.isSome: t.user.username else: "" -# let tweet = if retweet.len > 0: t.retweet.get else: t +# let retweet = if tweet.retweet.isSome: tweet.user.username else: "" +# let tweet = if retweet.len > 0: tweet.retweet.get else: tweet # let link = getLink(tweet) # if link in links: continue # end if @@ -113,7 +113,7 @@ ${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)} #end proc # -#proc renderListRss*(tweets: seq[Chain]; list: List; cfg: Config): string = +#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string = #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}" #result = "" @@ -130,7 +130,7 @@ ${renderRssTweets(tweets, cfg)} #end proc # -#proc renderSearchRss*(tweets: seq[Chain]; name, param: string; cfg: Config): string = +#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string = #let link = &"{getUrlPrefix(cfg)}/search" #let escName = xmltree.escape(name) #result = "" diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 8ae888e..abeb6d3 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -39,7 +39,7 @@ proc renderNoneFound(): VNode = h2(class="timeline-none"): text "No items found" -proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode = +proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="thread-line")): let sortedThread = thread.sortedByIt(it.id) for i, tweet in sortedThread: @@ -106,9 +106,9 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; var retweets: seq[int64] for thread in results.content: - if thread.content.len == 1: + if thread.len == 1: let - tweet = thread.content[0] + tweet = thread[0] retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 if retweetId in retweets or tweet.id in retweets or @@ -121,7 +121,7 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; hasThread = get(tweet.retweet).hasThread renderTweet(tweet, prefs, path, showThread=hasThread) else: - renderThread(thread.content, prefs, path) + renderThread(thread, prefs, path) if results.bottom.len > 0: renderMore(results.query, results.bottom) From 67203a431d9242e2f0d5fbdbfa86ba55f0a4cc54 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 12 Jul 2023 03:37:44 +0200 Subject: [PATCH 013/309] Add back search --- src/api.nim | 16 ++++++++++++++++ src/consts.nim | 1 + src/parser.nim | 34 ++++++++++++++++++++++++++++++++-- src/routes/rss.nim | 38 +++++++++++++++++++------------------- src/routes/search.nim | 14 +++++--------- src/routes/timeline.nim | 8 +++----- src/tokens.nim | 4 ++-- 7 files changed, 78 insertions(+), 37 deletions(-) diff --git a/src/api.nim b/src/api.nim index d99eb3d..60af68d 100644 --- a/src/api.nim +++ b/src/api.nim @@ -115,6 +115,22 @@ proc getGraphSearch*(query: Query; after=""): Future[Profile] {.async.} = result = Profile(tweets: parseGraphSearch(await fetch(url, Api.search), after)) result.tweets.query = query +proc getTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = + let q = genQueryParam(query) + if q.len == 0 or q == emptyQuery: + return Timeline(query: query, beginning: true) + + let url = tweetSearch ? genParams({ + "q": q, + "tweet_search_mode": "live", + "max_id": after + }) + + result = parseTweetSearch(await fetch(url, Api.search)) + result.query = query + if after.len == 0: + result.beginning = true + proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = if query.text.len == 0: return Result[User](query: query, beginning: true) diff --git a/src/consts.nim b/src/consts.nim index 184f9da..8dd1b14 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -9,6 +9,7 @@ const photoRail* = api / "1.1/statuses/media_timeline.json" userSearch* = api / "1.1/users/search.json" + tweetSearch* = api / "1.1/search/tweets.json" graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" diff --git a/src/parser.nim b/src/parser.nim index 5dc96df..f298160 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -82,12 +82,16 @@ proc parseVideo(js: JsonNode): Video = result = Video( thumb: js{"media_url_https"}.getImageStr, views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt), - available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available", + available: true, title: js{"ext_alt_text"}.getStr, durationMs: js{"video_info", "duration_millis"}.getInt # playbackType: mp4 ) + with status, js{"ext_media_availability", "status"}: + if status.getStr.len > 0 and status.getStr.toLowerAscii != "available": + result.available = false + with title, js{"additional_media_info", "title"}: result.title = title.getStr @@ -219,7 +223,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = if result.hasThread and result.threadId == 0: result.threadId = js{"self_thread", "id_str"}.getId - if js{"is_quote_status"}.getBool: + if "retweeted_status" in js: + result.retweet = some Tweet() + elif js{"is_quote_status"}.getBool: result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId) # legacy @@ -281,6 +287,30 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.text.removeSuffix(" Learn more.") result.available = false +proc parseLegacyTweet(js: JsonNode): Tweet = + result = parseTweet(js, js{"card"}) + if not result.isNil and result.available: + result.user = parseUser(js{"user"}) + + if result.quote.isSome: + result.quote = some parseLegacyTweet(js{"quoted_status"}) + +proc parseTweetSearch*(js: JsonNode): Timeline = + if js.kind == JNull or "statuses" notin js: + return Timeline(beginning: true) + + for tweet in js{"statuses"}: + let parsed = parseLegacyTweet(tweet) + + if parsed.retweet.isSome: + parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"}) + + result.content.add @[parsed] + + let cursor = js{"search_metadata", "next_results"}.getStr + if cursor.len > 0 and "max_id" in cursor: + result.bottom = cursor[cursor.find("=") + 1 .. cursor.find("&q=")] + proc finalizeTweet(global: GlobalObjects; id: string): Tweet = let intId = if id.len > 0: parseBiggestInt(id) else: 0 result = global.tweets.getOrDefault(id, Tweet(id: intId)) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 8eec399..d378396 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -27,7 +27,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. else: var q = query q.fromUser = names - profile = await getGraphSearch(q, after) + profile.tweets = await getTweetSearch(q, after) # this is kinda dumb profile.user = User( username: name, @@ -59,29 +59,29 @@ template respRss*(rss, page) = proc createRssRouter*(cfg: Config) = router rss: - # get "/search/rss": - # cond cfg.enableRss - # if @"q".len > 200: - # resp Http400, showError("Search input too long.", cfg) + get "/search/rss": + cond cfg.enableRss + if @"q".len > 200: + resp Http400, showError("Search input too long.", cfg) - # let query = initQuery(params(request)) - # if query.kind != tweets: - # resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) + let query = initQuery(params(request)) + if query.kind != tweets: + resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) - # let - # cursor = getCursor() - # key = redisKey("search", $hash(genQueryUrl(query)), cursor) + let + cursor = getCursor() + key = redisKey("search", $hash(genQueryUrl(query)), cursor) - # var rss = await getCachedRss(key) - # if rss.cursor.len > 0: - # respRss(rss, "Search") + var rss = await getCachedRss(key) + if rss.cursor.len > 0: + respRss(rss, "Search") - # let tweets = await getGraphSearch(query, cursor) - # rss.cursor = tweets.bottom - # rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) + let tweets = await getTweetSearch(query, cursor) + rss.cursor = tweets.bottom + rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) - # await cacheRss(key, rss) - # respRss(rss, "Search") + await cacheRss(key, rss) + respRss(rss, "Search") get "/@name/rss": cond cfg.enableRss diff --git a/src/routes/search.nim b/src/routes/search.nim index ed2c397..c270df5 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -34,15 +34,11 @@ proc createSearchRouter*(cfg: Config) = users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) of tweets: - # let - # tweets = await getGraphSearch(query, getCursor()) - # rss = "/search/rss?" & genQueryUrl(query) - # resp renderMain(renderTweetSearch(tweets, prefs, getPath()), - # request, cfg, prefs, title, rss=rss) - var fakeTimeline = Timeline(beginning: true) - fakeTimeline.content.add Tweet(tombstone: "Tweet search is unavailable for now") - - resp renderMain(renderTweetSearch(fakeTimeline, prefs, getPath()), request, cfg, prefs, title) + let + tweets = await getTweetSearch(query, getCursor()) + rss = "/search/rss?" & genQueryUrl(query) + resp renderMain(renderTweetSearch(tweets, prefs, getPath()), + request, cfg, prefs, title, rss=rss) else: resp Http404, showError("Invalid search", cfg) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index e62c9e0..ef3d012 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -56,8 +56,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) - else: Profile(tweets: Timeline(beginning: true, content: @[@[Tweet(tombstone: "Tweet search is unavailable for now")]])) - # else: await getGraphSearch(query, after) + else: Profile(tweets: await getTweetSearch(query, after)) result.user = await user result.photoRail = await rail @@ -71,9 +70,8 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = if query.fromUser.len != 1: let - # timeline = await getGraphSearch(query, after) - timeline = Profile(tweets: Timeline(beginning: true, content: @[@[Tweet(tombstone: "This features is unavailable for now")]])) - html = renderTweetSearch(timeline.tweets, prefs, getPath()) + timeline = await getTweetSearch(query, after) + html = renderTweetSearch(timeline, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins) diff --git a/src/tokens.nim b/src/tokens.nim index 6643de3..531f557 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -41,9 +41,9 @@ proc getPoolJson*(): JsonNode = let maxReqs = case api - of Api.timeline: 180 + of Api.timeline, Api.search: 180 of Api.userTweets, Api.userTweetsAndReplies, Api.userRestId, - Api.userScreenName, Api.tweetDetail, Api.tweetResult, Api.search: 500 + Api.userScreenName, Api.tweetDetail, Api.tweetResult: 500 of Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia: 500 of Api.userSearch: 900 reqs = maxReqs - token.apis[api].remaining From afbdbd293e30f614ee288731717868c6d618b55f Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 12 Jul 2023 03:47:37 +0200 Subject: [PATCH 014/309] Fix protected user photo rail crash --- src/parser.nim | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/parser.nim b/src/parser.nim index f298160..b988cf7 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -387,6 +387,10 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline = result.top = cursor{"value"}.getStr proc parsePhotoRail*(js: JsonNode): PhotoRail = + with error, js{"error"}: + if error.getStr == "Not authorized.": + return + for tweet in js: let t = parseTweet(tweet, js{"tweet_card"}) From 4c4d5485a06c37977c54a9ae226bb36cd4dadeab Mon Sep 17 00:00:00 2001 From: Jakub Wilk Date: Fri, 14 Jul 2023 18:11:56 +0200 Subject: [PATCH 015/309] Fix typo (#943) --- src/nitter.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nitter.nim b/src/nitter.nim index 627af75..25a569d 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -87,7 +87,7 @@ routes: error BadClientError: echo error.exc.name, ": ", error.exc.msg - resp Http500, showError("Network error occured, please try again.", cfg) + resp Http500, showError("Network error occurred, please try again.", cfg) error RateLimitError: const link = a("another instance", href = instancesUrl) From f881226b223f1650f7f1621991baa38513ddb61f Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 14 Jul 2023 21:35:37 +0200 Subject: [PATCH 016/309] Fix video embed --- src/views/embed.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/embed.nim b/src/views/embed.nim index a884cf3..ba49f45 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -11,7 +11,7 @@ const doctype = "\n" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = let thumb = get(tweet.video).thumb let vidUrl = getVideoEmbed(cfg, tweet.id) - let prefs = Prefs(hlsPlayback: true) + let prefs = Prefs(hlsPlayback: true, mp4Playback: true) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) From cc5841df308506356d329662d0f0c2ec4713a35c Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 21 Jul 2023 18:56:39 +0200 Subject: [PATCH 017/309] Use old timeline endpoint --- src/api.nim | 9 ++++++++- src/consts.nim | 14 ++++++++++++++ src/parser.nim | 36 +++++++++++++++++++++++++----------- src/routes/timeline.nim | 2 +- src/tokens.nim | 6 ++++-- src/types.nim | 1 + 6 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/api.nim b/src/api.nim index 60af68d..e8d0830 100644 --- a/src/api.nim +++ b/src/api.nim @@ -33,6 +33,13 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi js = await fetch(url ? params, apiId) result = parseGraphTimeline(js, "user", after) +proc getTimeline*(id: string; after=""; replies=false): Future[Profile] {.async.} = + if id.len == 0: return + let + ps = genParams({"userId": id, "include_tweet_replies": $replies}, after) + url = oldUserTweets / (id & ".json") ? ps + result = parseTimeline(await fetch(url, Api.timeline), after) + proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let @@ -155,7 +162,7 @@ proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = ps = genParams({"screen_name": name, "trim_user": "true"}, count="18", ext=false) url = photoRail ? ps - result = parsePhotoRail(await fetch(url, Api.timeline)) + result = parsePhotoRail(await fetch(url, Api.photoRail)) proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) diff --git a/src/consts.nim b/src/consts.nim index 8dd1b14..7ba09fb 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -11,6 +11,8 @@ const userSearch* = api / "1.1/users/search.json" tweetSearch* = api / "1.1/search/tweets.json" + oldUserTweets* = api / "2/timeline/profile" + graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" @@ -98,6 +100,18 @@ const "includeHasBirdwatchNotes": false }""" + oldUserTweetsVariables* = """{ + "userId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false, + "withVoice": false, + "withV2Timeline": true +} +""" + userTweetsVariables* = """{ "rest_id": "$1", $2 "count": 20 diff --git a/src/parser.nim b/src/parser.nim index b988cf7..193d77f 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -329,6 +329,16 @@ proc finalizeTweet(global: GlobalObjects; id: string): Tweet = else: result.retweet = some Tweet() +proc parsePin(js: JsonNode; global: GlobalObjects): Tweet = + let pin = js{"pinEntry", "entry", "entryId"}.getStr + if pin.len == 0: return + + let id = pin.getId + if id notin global.tweets: return + + global.tweets[id].pinned = true + return finalizeTweet(global, id) + proc parseGlobalObjects(js: JsonNode): GlobalObjects = result = GlobalObjects() let @@ -339,24 +349,28 @@ proc parseGlobalObjects(js: JsonNode): GlobalObjects = result.users[k] = parseUser(v, k) for k, v in tweets: - var tweet = parseTweet(v, v{"tweet_card"}) + var tweet = parseTweet(v, v{"card"}) if tweet.user.id in result.users: tweet.user = result.users[tweet.user.id] result.tweets[k] = tweet -proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNode) = +proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) = if js.kind != JArray or js.len == 0: return for i in js: + if res.tweets.beginning and i{"pinEntry"}.notNull: + with pin, parsePin(i, global): + res.pinned = some pin + with r, i{"replaceEntry", "entry"}: if "top" in r{"entryId"}.getStr: - res.top = r.getCursor + res.tweets.top = r.getCursor elif "bottom" in r{"entryId"}.getStr: - res.bottom = r.getCursor + res.tweets.bottom = r.getCursor -proc parseTimeline*(js: JsonNode; after=""): Timeline = - result = Timeline(beginning: after.len == 0) +proc parseTimeline*(js: JsonNode; after=""): Profile = + result = Profile(tweets: Timeline(beginning: after.len == 0)) let global = parseGlobalObjects(? js) let instructions = ? js{"timeline", "instructions"} @@ -374,17 +388,17 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline = if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry: let tweet = finalizeTweet(global, e.getEntryId) if not tweet.available: continue - result.content.add tweet + result.tweets.content.add tweet elif "cursor-top" in entry: - result.top = e.getCursor + result.tweets.top = e.getCursor elif "cursor-bottom" in entry: - result.bottom = e.getCursor + result.tweets.bottom = e.getCursor elif entry.startsWith("sq-cursor"): with cursor, e{"content", "operation", "cursor"}: if cursor{"cursorType"}.getStr == "Bottom": - result.bottom = cursor{"value"}.getStr + result.tweets.bottom = cursor{"value"}.getStr else: - result.top = cursor{"value"}.getStr + result.tweets.top = cursor{"value"}.getStr proc parsePhotoRail*(js: JsonNode): PhotoRail = with error, js{"error"}: diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index ef3d012..b574631 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -53,7 +53,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; result = case query.kind - of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) + of posts: await getTimeline(userId, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) else: Profile(tweets: await getTweetSearch(query, after)) diff --git a/src/tokens.nim b/src/tokens.nim index 531f557..8a25257 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -41,8 +41,10 @@ proc getPoolJson*(): JsonNode = let maxReqs = case api - of Api.timeline, Api.search: 180 - of Api.userTweets, Api.userTweetsAndReplies, Api.userRestId, + of Api.photoRail, Api.search: 180 + of Api.timeline: 187 + of Api.userTweets: 300 + of Api.userTweetsAndReplies, Api.userRestId, Api.userScreenName, Api.tweetDetail, Api.tweetResult: 500 of Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia: 500 of Api.userSearch: 900 diff --git a/src/types.nim b/src/types.nim index f7d5f6b..5db9ec3 100644 --- a/src/types.nim +++ b/src/types.nim @@ -18,6 +18,7 @@ type tweetDetail tweetResult timeline + photoRail search userSearch list From 50f821dbd8a7bbea75e9cdf2c4189e108b7b0541 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 22 Jul 2023 03:03:45 +0200 Subject: [PATCH 018/309] Use search instead of old timeline endpoint --- src/api.nim | 28 +++--- src/apiutils.nim | 2 +- src/consts.nim | 63 ++++++------- src/parser.nim | 194 ++++++++++++++++++++-------------------- src/routes/timeline.nim | 12 ++- tests/test_timeline.py | 8 +- tests/test_tweet.py | 36 ++++---- 7 files changed, 178 insertions(+), 165 deletions(-) diff --git a/src/api.nim b/src/api.nim index e8d0830..c7dc0e0 100644 --- a/src/api.nim +++ b/src/api.nim @@ -33,12 +33,12 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi js = await fetch(url ? params, apiId) result = parseGraphTimeline(js, "user", after) -proc getTimeline*(id: string; after=""; replies=false): Future[Profile] {.async.} = - if id.len == 0: return - let - ps = genParams({"userId": id, "include_tweet_replies": $replies}, after) - url = oldUserTweets / (id & ".json") ? ps - result = parseTimeline(await fetch(url, Api.timeline), after) +# proc getTimeline*(id: string; after=""; replies=false): Future[Profile] {.async.} = +# if id.len == 0: return +# let +# ps = genParams({"userId": id, "include_tweet_replies": $replies}, after) +# url = oldUserTweets / (id & ".json") ? ps +# result = parseTimeline(await fetch(url, Api.timeline), after) proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return @@ -123,20 +123,22 @@ proc getGraphSearch*(query: Query; after=""): Future[Profile] {.async.} = result.tweets.query = query proc getTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = - let q = genQueryParam(query) + var q = genQueryParam(query) + if q.len == 0 or q == emptyQuery: return Timeline(query: query, beginning: true) + if after.len > 0: + q &= " max_id:" & after + let url = tweetSearch ? genParams({ - "q": q, - "tweet_search_mode": "live", - "max_id": after + "q": q , + "modules": "status", + "result_type": "recent", }) - result = parseTweetSearch(await fetch(url, Api.search)) + result = parseTweetSearch(await fetch(url, Api.search), after) result.query = query - if after.len == 0: - result.beginning = true proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = if query.text.len == 0: diff --git a/src/apiutils.nim b/src/apiutils.nim index dbc6cca..c0c01d4 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -16,7 +16,7 @@ proc genParams*(pars: openArray[(string, string)] = @[]; cursor=""; for p in pars: result &= p if ext: - result &= ("ext", "mediaStats") + result &= ("ext", "mediaStats,isBlueVerified,isVerified,blue,blueVerified") result &= ("include_ext_alt_text", "1") result &= ("include_ext_media_availability", "1") if count.len > 0: diff --git a/src/consts.nim b/src/consts.nim index 7ba09fb..80a098f 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -9,9 +9,9 @@ const photoRail* = api / "1.1/statuses/media_timeline.json" userSearch* = api / "1.1/users/search.json" - tweetSearch* = api / "1.1/search/tweets.json" + tweetSearch* = api / "1.1/search/universal.json" - oldUserTweets* = api / "2/timeline/profile" + # oldUserTweets* = api / "2/timeline/profile" graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" @@ -28,27 +28,28 @@ const graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" timelineParams* = { - "include_profile_interstitial_type": "0", - "include_blocking": "0", - "include_blocked_by": "0", - "include_followed_by": "0", - "include_want_retweets": "0", - "include_mute_edge": "0", - "include_can_dm": "0", - "include_can_media_tag": "1", - "include_ext_is_blue_verified": "1", - "skip_status": "1", - "cards_platform": "Web-12", - "include_cards": "1", - "include_composer_source": "0", - "include_reply_count": "1", + "cards_platform": "Web-13", "tweet_mode": "extended", - "include_entities": "1", - "include_user_entities": "1", - "include_ext_media_color": "0", + "ui_lang": "en-US", "send_error_codes": "1", "simple_quoted_tweet": "1", - "include_quote_count": "1" + "skip_status": "1", + "include_blocked_by": "0", + "include_blocking": "0", + "include_can_dm": "0", + "include_can_media_tag": "1", + "include_cards": "1", + "include_composer_source": "0", + "include_entities": "1", + "include_ext_is_blue_verified": "1", + "include_ext_media_color": "0", + "include_followed_by": "0", + "include_mute_edge": "0", + "include_profile_interstitial_type": "0", + "include_quote_count": "1", + "include_reply_count": "1", + "include_user_entities": "1", + "include_want_retweets": "0", }.toSeq gqlFeatures* = """{ @@ -100,17 +101,17 @@ const "includeHasBirdwatchNotes": false }""" - oldUserTweetsVariables* = """{ - "userId": "$1", $2 - "count": 20, - "includePromotedContent": false, - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false, - "withVoice": false, - "withV2Timeline": true -} -""" +# oldUserTweetsVariables* = """{ +# "userId": "$1", $2 +# "count": 20, +# "includePromotedContent": false, +# "withDownvotePerspective": false, +# "withReactionsMetadata": false, +# "withReactionsPerspective": false, +# "withVoice": false, +# "withV2Timeline": true +# } +# """ userTweetsVariables* = """{ "rest_id": "$1", $2 diff --git a/src/parser.nim b/src/parser.nim index 193d77f..991ca6e 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, options, tables, times, math +import strutils, options, times, math import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard @@ -295,110 +295,112 @@ proc parseLegacyTweet(js: JsonNode): Tweet = if result.quote.isSome: result.quote = some parseLegacyTweet(js{"quoted_status"}) -proc parseTweetSearch*(js: JsonNode): Timeline = - if js.kind == JNull or "statuses" notin js: - return Timeline(beginning: true) +proc parseTweetSearch*(js: JsonNode; after=""): Timeline = + result.beginning = after.len == 0 - for tweet in js{"statuses"}: - let parsed = parseLegacyTweet(tweet) - - if parsed.retweet.isSome: - parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"}) - - result.content.add @[parsed] - - let cursor = js{"search_metadata", "next_results"}.getStr - if cursor.len > 0 and "max_id" in cursor: - result.bottom = cursor[cursor.find("=") + 1 .. cursor.find("&q=")] - -proc finalizeTweet(global: GlobalObjects; id: string): Tweet = - let intId = if id.len > 0: parseBiggestInt(id) else: 0 - result = global.tweets.getOrDefault(id, Tweet(id: intId)) - - if result.quote.isSome: - let quote = get(result.quote).id - if $quote in global.tweets: - result.quote = some global.tweets[$quote] - else: - result.quote = some Tweet() - - if result.retweet.isSome: - let rt = get(result.retweet).id - if $rt in global.tweets: - result.retweet = some finalizeTweet(global, $rt) - else: - result.retweet = some Tweet() - -proc parsePin(js: JsonNode; global: GlobalObjects): Tweet = - let pin = js{"pinEntry", "entry", "entryId"}.getStr - if pin.len == 0: return - - let id = pin.getId - if id notin global.tweets: return - - global.tweets[id].pinned = true - return finalizeTweet(global, id) - -proc parseGlobalObjects(js: JsonNode): GlobalObjects = - result = GlobalObjects() - let - tweets = ? js{"globalObjects", "tweets"} - users = ? js{"globalObjects", "users"} - - for k, v in users: - result.users[k] = parseUser(v, k) - - for k, v in tweets: - var tweet = parseTweet(v, v{"card"}) - if tweet.user.id in result.users: - tweet.user = result.users[tweet.user.id] - result.tweets[k] = tweet - -proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) = - if js.kind != JArray or js.len == 0: + if js.kind == JNull or "modules" notin js or js{"modules"}.len == 0: return - for i in js: - if res.tweets.beginning and i{"pinEntry"}.notNull: - with pin, parsePin(i, global): - res.pinned = some pin + for item in js{"modules"}: + with tweet, item{"status", "data"}: + let parsed = parseLegacyTweet(tweet) - with r, i{"replaceEntry", "entry"}: - if "top" in r{"entryId"}.getStr: - res.tweets.top = r.getCursor - elif "bottom" in r{"entryId"}.getStr: - res.tweets.bottom = r.getCursor + if parsed.retweet.isSome: + parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"}) -proc parseTimeline*(js: JsonNode; after=""): Profile = - result = Profile(tweets: Timeline(beginning: after.len == 0)) - let global = parseGlobalObjects(? js) + result.content.add @[parsed] - let instructions = ? js{"timeline", "instructions"} - if instructions.len == 0: return + if result.content.len > 0: + result.bottom = $(result.content[^1][0].id - 1) - result.parseInstructions(global, instructions) +# proc finalizeTweet(global: GlobalObjects; id: string): Tweet = +# let intId = if id.len > 0: parseBiggestInt(id) else: 0 +# result = global.tweets.getOrDefault(id, Tweet(id: intId)) - var entries: JsonNode - for i in instructions: - if "addEntries" in i: - entries = i{"addEntries", "entries"} +# if result.quote.isSome: +# let quote = get(result.quote).id +# if $quote in global.tweets: +# result.quote = some global.tweets[$quote] +# else: +# result.quote = some Tweet() - for e in ? entries: - let entry = e{"entryId"}.getStr - if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry: - let tweet = finalizeTweet(global, e.getEntryId) - if not tweet.available: continue - result.tweets.content.add tweet - elif "cursor-top" in entry: - result.tweets.top = e.getCursor - elif "cursor-bottom" in entry: - result.tweets.bottom = e.getCursor - elif entry.startsWith("sq-cursor"): - with cursor, e{"content", "operation", "cursor"}: - if cursor{"cursorType"}.getStr == "Bottom": - result.tweets.bottom = cursor{"value"}.getStr - else: - result.tweets.top = cursor{"value"}.getStr +# if result.retweet.isSome: +# let rt = get(result.retweet).id +# if $rt in global.tweets: +# result.retweet = some finalizeTweet(global, $rt) +# else: +# result.retweet = some Tweet() + +# proc parsePin(js: JsonNode; global: GlobalObjects): Tweet = +# let pin = js{"pinEntry", "entry", "entryId"}.getStr +# if pin.len == 0: return + +# let id = pin.getId +# if id notin global.tweets: return + +# global.tweets[id].pinned = true +# return finalizeTweet(global, id) + +# proc parseGlobalObjects(js: JsonNode): GlobalObjects = +# result = GlobalObjects() +# let +# tweets = ? js{"globalObjects", "tweets"} +# users = ? js{"globalObjects", "users"} + +# for k, v in users: +# result.users[k] = parseUser(v, k) + +# for k, v in tweets: +# var tweet = parseTweet(v, v{"card"}) +# if tweet.user.id in result.users: +# tweet.user = result.users[tweet.user.id] +# result.tweets[k] = tweet + +# proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) = +# if js.kind != JArray or js.len == 0: +# return + +# for i in js: +# if res.tweets.beginning and i{"pinEntry"}.notNull: +# with pin, parsePin(i, global): +# res.pinned = some pin + +# with r, i{"replaceEntry", "entry"}: +# if "top" in r{"entryId"}.getStr: +# res.tweets.top = r.getCursor +# elif "bottom" in r{"entryId"}.getStr: +# res.tweets.bottom = r.getCursor + +# proc parseTimeline*(js: JsonNode; after=""): Profile = +# result = Profile(tweets: Timeline(beginning: after.len == 0)) +# let global = parseGlobalObjects(? js) + +# let instructions = ? js{"timeline", "instructions"} +# if instructions.len == 0: return + +# result.parseInstructions(global, instructions) + +# var entries: JsonNode +# for i in instructions: +# if "addEntries" in i: +# entries = i{"addEntries", "entries"} + +# for e in ? entries: +# let entry = e{"entryId"}.getStr +# if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry: +# let tweet = finalizeTweet(global, e.getEntryId) +# if not tweet.available: continue +# result.tweets.content.add tweet +# elif "cursor-top" in entry: +# result.tweets.top = e.getCursor +# elif "cursor-bottom" in entry: +# result.tweets.bottom = e.getCursor +# elif entry.startsWith("sq-cursor"): +# with cursor, e{"content", "operation", "cursor"}: +# if cursor{"cursorType"}.getStr == "Bottom": +# result.tweets.bottom = cursor{"value"}.getStr +# else: +# result.tweets.top = cursor{"value"}.getStr proc parsePhotoRail*(js: JsonNode): PhotoRail = with error, js{"error"}: diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index b574631..8b8a23c 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -53,7 +53,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; result = case query.kind - of posts: await getTimeline(userId, after) + # of posts: await getTimeline(userId, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) else: Profile(tweets: await getTweetSearch(query, after)) @@ -61,10 +61,18 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; result.user = await user result.photoRail = await rail + result.tweets.query = query + if result.user.protected or result.user.suspended: return - result.tweets.query = query + if not skipPinned and query.kind == posts and + result.user.pinnedTweet > 0 and after.len == 0: + let tweet = await getCachedTweet(result.user.pinnedTweet) + if not tweet.isNil: + tweet.pinned = true + tweet.user = result.user + result.pinned = some tweet proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = diff --git a/tests/test_timeline.py b/tests/test_timeline.py index dd78396..a630ae1 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -1,12 +1,12 @@ from base import BaseTestCase, Timeline from parameterized import parameterized -normal = [['mobile_test'], ['mobile_test_2']] +normal = [['jack'], ['elonmusk']] -after = [['mobile_test', 'HBaAgJPsqtGNhA0AAA%3D%3D'], - ['mobile_test_2', 'HBaAgJPsqtGNhA0AAA%3D%3D']] +after = [['jack', '1681686036294803456'], + ['elonmusk', '1681686036294803456']] -no_more = [['mobile_test_8?cursor=HBaAwJCsk%2F6%2FtgQAAA%3D%3D']] +no_more = [['mobile_test_8?cursor=1000']] empty = [['emptyuser'], ['mobile_test_10']] diff --git a/tests/test_tweet.py b/tests/test_tweet.py index e4231a4..7a3c4ed 100644 --- a/tests/test_tweet.py +++ b/tests/test_tweet.py @@ -80,16 +80,16 @@ retweet = [ class TweetTest(BaseTestCase): - @parameterized.expand(timeline) - def test_timeline(self, index, fullname, username, date, tid, text): - self.open_nitter(username) - tweet = get_timeline_tweet(index) - self.assert_exact_text(fullname, tweet.fullname) - self.assert_exact_text('@' + username, tweet.username) - self.assert_exact_text(date, tweet.date) - self.assert_text(text, tweet.text) - permalink = self.find_element(tweet.date + ' a') - self.assertIn(tid, permalink.get_attribute('href')) + # @parameterized.expand(timeline) + # def test_timeline(self, index, fullname, username, date, tid, text): + # self.open_nitter(username) + # tweet = get_timeline_tweet(index) + # self.assert_exact_text(fullname, tweet.fullname) + # self.assert_exact_text('@' + username, tweet.username) + # self.assert_exact_text(date, tweet.date) + # self.assert_text(text, tweet.text) + # permalink = self.find_element(tweet.date + ' a') + # self.assertIn(tid, permalink.get_attribute('href')) @parameterized.expand(status) def test_status(self, tid, fullname, username, date, text): @@ -123,14 +123,14 @@ class TweetTest(BaseTestCase): link = self.find_link_text(f'@{un}') self.assertIn(f'/{un}', link.get_property('href')) - @parameterized.expand(retweet) - def test_retweet(self, index, url, retweet_by, fullname, username, text): - self.open_nitter(url) - tweet = get_timeline_tweet(index) - self.assert_text(f'{retweet_by} retweeted', tweet.retweet) - self.assert_text(text, tweet.text) - self.assert_exact_text(fullname, tweet.fullname) - self.assert_exact_text(username, tweet.username) + # @parameterized.expand(retweet) + # def test_retweet(self, index, url, retweet_by, fullname, username, text): + # self.open_nitter(url) + # tweet = get_timeline_tweet(index) + # self.assert_text(f'{retweet_by} retweeted', tweet.retweet) + # self.assert_text(text, tweet.text) + # self.assert_exact_text(fullname, tweet.fullname) + # self.assert_exact_text(username, tweet.username) @parameterized.expand(invalid) def test_invalid_id(self, tweet): From 72d8f35cd1ec1205824711a41dab4b8d7a6b298a Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 22 Jul 2023 04:06:04 +0200 Subject: [PATCH 019/309] Search isn't rate limited --- src/tokens.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tokens.nim b/src/tokens.nim index 8a25257..b69786e 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -41,7 +41,8 @@ proc getPoolJson*(): JsonNode = let maxReqs = case api - of Api.photoRail, Api.search: 180 + of Api.search: 100000 + of Api.photoRail: 180 of Api.timeline: 187 of Api.userTweets: 300 of Api.userTweetsAndReplies, Api.userRestId, From 59a72831c749b2198cb83d1b7cee74a5d05da723 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Jul 2023 04:26:32 +0200 Subject: [PATCH 020/309] Apply cached profile verified status to tweets --- src/routes/timeline.nim | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 8b8a23c..bf2a08e 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -66,13 +66,17 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; if result.user.protected or result.user.suspended: return - if not skipPinned and query.kind == posts and - result.user.pinnedTweet > 0 and after.len == 0: - let tweet = await getCachedTweet(result.user.pinnedTweet) - if not tweet.isNil: - tweet.pinned = true - tweet.user = result.user - result.pinned = some tweet + if query.kind == posts: + if result.user.verified: + for chain in result.tweets.content: + if chain[0].user.id == result.user.id: + chain[0].user.verified = true + if not skipPinned and result.user.pinnedTweet > 0 and after.len == 0: + let tweet = await getCachedTweet(result.user.pinnedTweet) + if not tweet.isNil: + tweet.pinned = true + tweet.user = result.user + result.pinned = some tweet proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = From 39192bf191dfc8c5645aa8101afd04474b899897 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Jul 2023 10:18:50 +0200 Subject: [PATCH 021/309] Fix multi-timeline infinite scroll --- src/routes/timeline.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index bf2a08e..82dc45b 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -137,7 +137,7 @@ proc createTimelineRouter*(cfg: Config) = # used for the infinite scroll feature if @"scroll".len > 0: if query.fromUser.len != 1: - var timeline = (await getGraphSearch(query, after)).tweets + var timeline = await getTweetSearch(query, after) if timeline.content.len == 0: resp Http404 timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) From 20b5cce5dc6437ffc06ea53e9efd884f2fc66abe Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 24 Jul 2023 10:37:25 +0200 Subject: [PATCH 022/309] Retry infinite scroll errors --- public/js/infiniteScroll.js | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index 9939c03..be27e0c 100644 --- a/public/js/infiniteScroll.js +++ b/public/js/infiniteScroll.js @@ -5,7 +5,7 @@ function insertBeforeLast(node, elem) { } function getLoadMore(doc) { - return doc.querySelector('.show-more:not(.timeline-item)'); + return doc.querySelector(".show-more:not(.timeline-item)"); } function isDuplicate(item, itemClass) { @@ -15,18 +15,19 @@ function isDuplicate(item, itemClass) { return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; } -window.onload = function() { +window.onload = function () { const url = window.location.pathname; const isTweet = url.indexOf("/status/") !== -1; const containerClass = isTweet ? ".replies" : ".timeline"; - const itemClass = containerClass + ' > div:not(.top-ref)'; + const itemClass = containerClass + " > div:not(.top-ref)"; var html = document.querySelector("html"); var container = document.querySelector(containerClass); var loading = false; - window.addEventListener('scroll', function() { + function handleScroll(failed) { if (loading) return; + if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { loading = true; var loadMore = getLoadMore(document); @@ -35,13 +36,15 @@ window.onload = function() { loadMore.children[0].text = "Loading..."; var url = new URL(loadMore.children[0].href); - url.searchParams.append('scroll', 'true'); + url.searchParams.append("scroll", "true"); fetch(url.toString()).then(function (response) { + if (response.status === 404) throw "error"; + return response.text(); }).then(function (html) { var parser = new DOMParser(); - var doc = parser.parseFromString(html, 'text/html'); + var doc = parser.parseFromString(html, "text/html"); loadMore.remove(); for (var item of doc.querySelectorAll(itemClass)) { @@ -57,10 +60,18 @@ window.onload = function() { if (isTweet) container.appendChild(newLoadMore); else insertBeforeLast(container, newLoadMore); }).catch(function (err) { - console.warn('Something went wrong.', err); - loading = true; + console.warn("Something went wrong.", err); + if (failed > 3) { + loadMore.children[0].text = "Error"; + return; + } + + loading = false; + handleScroll((failed || 0) + 1); }); } - }); + } + + window.addEventListener("scroll", () => handleScroll()); }; // @license-end From 5725780c990cd55f00ed6558d052f0e5a148652a Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 6 Aug 2023 21:02:22 +0200 Subject: [PATCH 023/309] Bump Nim version in Docker image --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c100394..138dc64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:1.6.10-alpine-regular as nim +FROM nimlang/nim:2.0.0-alpine-regular as nim LABEL maintainer="setenforce@protonmail.com" RUN apk --no-cache add libsass-dev pcre From 624394430c0989d18c279153006c6a7e48f4dd03 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 8 Aug 2023 02:09:56 +0200 Subject: [PATCH 024/309] Use legacy timeline/user endpoint for Tweets tab --- src/api.nim | 10 ++++++++ src/apiutils.nim | 2 +- src/consts.nim | 23 +++++++----------- src/parser.nim | 52 +++++++++++++++++++++++++++++++++++++++-- src/parserutils.nim | 6 +++++ src/routes/timeline.nim | 17 +------------- src/tokens.nim | 8 +++---- src/types.nim | 1 + 8 files changed, 81 insertions(+), 38 deletions(-) diff --git a/src/api.nim b/src/api.nim index c7dc0e0..c313aa2 100644 --- a/src/api.nim +++ b/src/api.nim @@ -40,6 +40,16 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi # url = oldUserTweets / (id & ".json") ? ps # result = parseTimeline(await fetch(url, Api.timeline), after) +proc getUserTimeline*(id: string; after=""): Future[Profile] {.async.} = + var ps = genParams({"id": id}) + if after.len > 0: + ps.add ("down_cursor", after) + + let + url = legacyUserTweets ? ps + js = await fetch(url, Api.userTimeline) + result = parseUserTimeline(js, after) + proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let diff --git a/src/apiutils.nim b/src/apiutils.nim index c0c01d4..1da971a 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -16,8 +16,8 @@ proc genParams*(pars: openArray[(string, string)] = @[]; cursor=""; for p in pars: result &= p if ext: - result &= ("ext", "mediaStats,isBlueVerified,isVerified,blue,blueVerified") result &= ("include_ext_alt_text", "1") + result &= ("include_ext_media_stats", "1") result &= ("include_ext_media_availability", "1") if count.len > 0: result &= ("count", count) diff --git a/src/consts.nim b/src/consts.nim index 80a098f..a25f6ea 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -7,6 +7,7 @@ const api = parseUri("https://api.twitter.com") activate* = $(api / "1.1/guest/activate.json") + legacyUserTweets* = api / "1.1/timeline/user.json" photoRail* = api / "1.1/statuses/media_timeline.json" userSearch* = api / "1.1/users/search.json" tweetSearch* = api / "1.1/search/universal.json" @@ -28,28 +29,20 @@ const graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" timelineParams* = { - "cards_platform": "Web-13", - "tweet_mode": "extended", - "ui_lang": "en-US", - "send_error_codes": "1", - "simple_quoted_tweet": "1", - "skip_status": "1", - "include_blocked_by": "0", - "include_blocking": "0", - "include_can_dm": "0", "include_can_media_tag": "1", "include_cards": "1", - "include_composer_source": "0", "include_entities": "1", - "include_ext_is_blue_verified": "1", - "include_ext_media_color": "0", - "include_followed_by": "0", - "include_mute_edge": "0", "include_profile_interstitial_type": "0", "include_quote_count": "1", "include_reply_count": "1", "include_user_entities": "1", - "include_want_retweets": "0", + "include_ext_reply_count": "1", + "include_ext_is_blue_verified": "1", + "include_ext_media_color": "0", + "cards_platform": "Web-13", + "tweet_mode": "extended", + "send_error_codes": "1", + "simple_quoted_tweet": "1" }.toSeq gqlFeatures* = """{ diff --git a/src/parser.nim b/src/parser.nim index 991ca6e..c7d8bd1 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, options, times, math +import strutils, options, times, math, tables import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard @@ -81,7 +81,7 @@ proc parseGif(js: JsonNode): Gif = proc parseVideo(js: JsonNode): Video = result = Video( thumb: js{"media_url_https"}.getImageStr, - views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt), + views: getVideoViewCount(js), available: true, title: js{"ext_alt_text"}.getStr, durationMs: js{"video_info", "duration_millis"}.getInt @@ -313,6 +313,54 @@ proc parseTweetSearch*(js: JsonNode; after=""): Timeline = if result.content.len > 0: result.bottom = $(result.content[^1][0].id - 1) +proc parseUserTimelineTweet(tweet: JsonNode; users: TableRef[string, User]): Tweet = + result = parseTweet(tweet, tweet{"card"}) + + if result.isNil or not result.available: + return + + with user, tweet{"user"}: + let userId = user{"id_str"}.getStr + if user{"ext_is_blue_verified"}.getBool(false): + users[userId].verified = users[userId].verified or true + result.user = users[userId] + +proc parseUserTimeline*(js: JsonNode; after=""): Profile = + result = Profile(tweets: Timeline(beginning: after.len == 0)) + + if js.kind == JNull or "response" notin js or "twitter_objects" notin js: + return + + var users = newTable[string, User]() + for userId, user in js{"twitter_objects", "users"}: + users[userId] = parseUser(user) + + for entity in js{"response", "timeline"}: + let + tweetId = entity{"tweet", "id"}.getId + isPinned = entity{"tweet", "is_pinned"}.getBool(false) + + with tweet, js{"twitter_objects", "tweets", $tweetId}: + var parsed = parseUserTimelineTweet(tweet, users) + + if not parsed.isNil and parsed.available: + if parsed.quote.isSome: + parsed.quote = some parseUserTimelineTweet(tweet{"quoted_status"}, users) + + if parsed.retweet.isSome: + let retweet = parseUserTimelineTweet(tweet{"retweeted_status"}, users) + if retweet.quote.isSome: + retweet.quote = some parseUserTimelineTweet(tweet{"retweeted_status", "quoted_status"}, users) + parsed.retweet = some retweet + + if isPinned: + parsed.pinned = true + result.pinned = some parsed + else: + result.tweets.content.add parsed + + result.tweets.bottom = js{"response", "cursor", "bottom"}.getStr + # proc finalizeTweet(global: GlobalObjects; id: string): Tweet = # let intId = if id.len > 0: parseBiggestInt(id) else: 0 # result = global.tweets.getOrDefault(id, Tweet(id: intId)) diff --git a/src/parserutils.nim b/src/parserutils.nim index f28bd52..c65052e 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -148,6 +148,12 @@ proc getMp4Resolution*(url: string): int = # cannot determine resolution (e.g. m3u8/non-mp4 video) return 0 +proc getVideoViewCount*(js: JsonNode): string = + with stats, js{"ext_media_stats"}: + return stats{"view_count"}.getStr($stats{"viewCount"}.getInt) + + return $js{"mediaStats", "viewCount"}.getInt(0) + proc extractSlice(js: JsonNode): Slice[int] = result = js["indices"][0].getInt ..< js["indices"][1].getInt diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 82dc45b..8d02b68 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -53,7 +53,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; result = case query.kind - # of posts: await getTimeline(userId, after) + of posts: await getUserTimeline(userId, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) else: Profile(tweets: await getTweetSearch(query, after)) @@ -63,21 +63,6 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; result.tweets.query = query - if result.user.protected or result.user.suspended: - return - - if query.kind == posts: - if result.user.verified: - for chain in result.tweets.content: - if chain[0].user.id == result.user.id: - chain[0].user.verified = true - if not skipPinned and result.user.pinnedTweet > 0 and after.len == 0: - let tweet = await getCachedTweet(result.user.pinnedTweet) - if not tweet.isNil: - tweet.pinned = true - tweet.user = result.user - result.pinned = some tweet - proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = if query.fromUser.len != 1: diff --git a/src/tokens.nim b/src/tokens.nim index b69786e..decf228 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -44,10 +44,10 @@ proc getPoolJson*(): JsonNode = of Api.search: 100000 of Api.photoRail: 180 of Api.timeline: 187 - of Api.userTweets: 300 + of Api.userTweets, Api.userTimeline: 300 of Api.userTweetsAndReplies, Api.userRestId, - Api.userScreenName, Api.tweetDetail, Api.tweetResult: 500 - of Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia: 500 + Api.userScreenName, Api.tweetDetail, Api.tweetResult, + Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia: 500 of Api.userSearch: 900 reqs = maxReqs - token.apis[api].remaining @@ -161,6 +161,6 @@ proc initTokenPool*(cfg: Config) {.async.} = enableLogging = cfg.enableDebug while true: - if tokenPool.countIt(not it.isLimited(Api.timeline)) < cfg.minTokens: + if tokenPool.countIt(not it.isLimited(Api.userTimeline)) < cfg.minTokens: await poolTokens(min(4, cfg.minTokens - tokenPool.len)) await sleepAsync(2000) diff --git a/src/types.nim b/src/types.nim index 5db9ec3..1a47d25 100644 --- a/src/types.nim +++ b/src/types.nim @@ -18,6 +18,7 @@ type tweetDetail tweetResult timeline + userTimeline photoRail search userSearch From 967f5e50f9c2ba4ac50dfb39fc559e104cedbc99 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 8 Aug 2023 02:15:32 +0200 Subject: [PATCH 025/309] Update and disable some tests --- tests/test_profile.py | 2 +- tests/test_timeline.py | 7 +++---- tests/test_tweet_media.py | 14 +++++++------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/test_profile.py b/tests/test_profile.py index 4c75ad2..38c5189 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -4,7 +4,7 @@ from parameterized import parameterized profiles = [ ['mobile_test', 'Test account', 'Test Account. test test Testing username with @mobile_test_2 and a #hashtag', - 'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '100'], + 'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '98'], ['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13'] ] diff --git a/tests/test_timeline.py b/tests/test_timeline.py index a630ae1..90eaa28 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -13,10 +13,9 @@ empty = [['emptyuser'], ['mobile_test_10']] protected = [['mobile_test_7'], ['Empty_user']] photo_rail = [['mobile_test', [ - 'BzUnaDFCUAAmrjs', 'Bo0nDsYIYAIjqVn', 'Bos--KNIQAAA7Li', 'Boq1sDJIYAAxaoi', - 'BonISmPIEAAhP3G', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG', - 'Bn8QIG3IYAA0IGT', 'Bn8O3QeIUAAONai', 'Bn8NGViIAAATNG4', 'BkKovdrCUAAEz79', - 'BkKoe_oCIAASAqr', 'BkKoRLNCAAAYfDf', 'BkKndxoCQAE1vFt', 'BPEmIbYCMAE44dl' + 'Bo0nDsYIYAIjqVn', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG', + 'Bn8QIG3IYAA0IGT', 'Bn8O3QeIUAAONai', 'Bn8NGViIAAATNG4', 'BkKoRLNCAAAYfDf', + 'BkKndxoCQAE1vFt' ]]] diff --git a/tests/test_tweet_media.py b/tests/test_tweet_media.py index 233990e..7a00983 100644 --- a/tests/test_tweet_media.py +++ b/tests/test_tweet_media.py @@ -28,14 +28,14 @@ video_m3u8 = [ ] gallery = [ - ['mobile_test/status/451108446603980803', [ - ['BkKovdrCUAAEz79', 'BkKovdcCEAAfoBO'] - ]], + # ['mobile_test/status/451108446603980803', [ + # ['BkKovdrCUAAEz79', 'BkKovdcCEAAfoBO'] + # ]], - ['mobile_test/status/471539824713691137', [ - ['Bos--KNIQAAA7Li', 'Bos--FAIAAAWpah'], - ['Bos--IqIQAAav23'] - ]], + # ['mobile_test/status/471539824713691137', [ + # ['Bos--KNIQAAA7Li', 'Bos--FAIAAAWpah'], + # ['Bos--IqIQAAav23'] + # ]], ['mobile_test/status/469530783384743936', [ ['BoQbwJAIUAA0QCY', 'BoQbwN1IMAAuTiP'], From 54e6ce14ac48409c0552b96e1dadf674c1926c83 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 8 Aug 2023 02:35:43 +0200 Subject: [PATCH 026/309] Simplify photo rail test --- tests/test_timeline.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_timeline.py b/tests/test_timeline.py index 90eaa28..9261b44 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -12,11 +12,7 @@ empty = [['emptyuser'], ['mobile_test_10']] protected = [['mobile_test_7'], ['Empty_user']] -photo_rail = [['mobile_test', [ - 'Bo0nDsYIYAIjqVn', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG', - 'Bn8QIG3IYAA0IGT', 'Bn8O3QeIUAAONai', 'Bn8NGViIAAATNG4', 'BkKoRLNCAAAYfDf', - 'BkKndxoCQAE1vFt' -]]] +photo_rail = [['mobile_test', ['Bo0nDsYIYAIjqVn', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG']]] class TweetTest(BaseTestCase): From d7ca353a55ea3440a2ec1f09155951210a374cc7 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 8 Aug 2023 02:49:58 +0200 Subject: [PATCH 027/309] Disable photo rail test --- tests/test_timeline.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_timeline.py b/tests/test_timeline.py index 9261b44..b56d6ad 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -55,10 +55,10 @@ class TweetTest(BaseTestCase): self.assert_element_absent(Timeline.older) self.assert_element_absent(Timeline.end) - @parameterized.expand(photo_rail) - def test_photo_rail(self, username, images): - self.open_nitter(username) - self.assert_element_visible(Timeline.photo_rail) - for i, url in enumerate(images): - img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src') - self.assertIn(url, img) + #@parameterized.expand(photo_rail) + #def test_photo_rail(self, username, images): + #self.open_nitter(username) + #self.assert_element_visible(Timeline.photo_rail) + #for i, url in enumerate(images): + #img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src') + #self.assertIn(url, img) From 3572dd77719f549d18aa0872b04564ede1091ca3 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 19 Aug 2023 00:25:14 +0200 Subject: [PATCH 028/309] Replace tokens with guest accounts, swap endpoints --- nitter.nimble | 2 +- src/api.nim | 68 ++++---------- src/apiutils.nim | 54 +++++++---- src/consts.nim | 8 +- src/nitter.nim | 13 ++- src/parser.nim | 193 ++++------------------------------------ src/redis_cache.nim | 18 ++-- src/routes/rss.nim | 4 +- src/routes/search.nim | 4 +- src/routes/timeline.nim | 8 +- src/tokens.nim | 158 +++++++++++--------------------- src/types.nim | 11 ++- 12 files changed, 159 insertions(+), 382 deletions(-) diff --git a/nitter.nimble b/nitter.nimble index 7771b31..e6a1909 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -23,7 +23,7 @@ requires "https://github.com/zedeus/redis#d0a0e6f" requires "zippy#ca5989a" requires "flatty#e668085" requires "jsony#ea811be" - +requires "oauth#b8c163b" # Tasks diff --git a/src/api.nim b/src/api.nim index c313aa2..d6a4564 100644 --- a/src/api.nim +++ b/src/api.nim @@ -33,23 +33,6 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi js = await fetch(url ? params, apiId) result = parseGraphTimeline(js, "user", after) -# proc getTimeline*(id: string; after=""; replies=false): Future[Profile] {.async.} = -# if id.len == 0: return -# let -# ps = genParams({"userId": id, "include_tweet_replies": $replies}, after) -# url = oldUserTweets / (id & ".json") ? ps -# result = parseTimeline(await fetch(url, Api.timeline), after) - -proc getUserTimeline*(id: string; after=""): Future[Profile] {.async.} = - var ps = genParams({"id": id}) - if after.len > 0: - ps.add ("down_cursor", after) - - let - url = legacyUserTweets ? ps - js = await fetch(url, Api.userTimeline) - result = parseUserTimeline(js, after) - proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let @@ -112,10 +95,10 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = if after.len > 0: result.replies = await getReplies(id, after) -proc getGraphSearch*(query: Query; after=""): Future[Profile] {.async.} = +proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = let q = genQueryParam(query) if q.len == 0 or q == emptyQuery: - return Profile(tweets: Timeline(query: query, beginning: true)) + return Timeline(query: query, beginning: true) var variables = %*{ @@ -129,44 +112,29 @@ proc getGraphSearch*(query: Query; after=""): Future[Profile] {.async.} = if after.len > 0: variables["cursor"] = % after let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} - result = Profile(tweets: parseGraphSearch(await fetch(url, Api.search), after)) - result.tweets.query = query - -proc getTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = - var q = genQueryParam(query) - - if q.len == 0 or q == emptyQuery: - return Timeline(query: query, beginning: true) - - if after.len > 0: - q &= " max_id:" & after - - let url = tweetSearch ? genParams({ - "q": q , - "modules": "status", - "result_type": "recent", - }) - - result = parseTweetSearch(await fetch(url, Api.search), after) + result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) result.query = query -proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = +proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = if query.text.len == 0: return Result[User](query: query, beginning: true) - var url = userSearch ? { - "q": query.text, - "skip_status": "1", - "count": "20", - "page": page - } + var + variables = %*{ + "rawQuery": query.text, + "count": 20, + "product": "People", + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false + } + if after.len > 0: + variables["cursor"] = % after + result.beginning = false - result = parseUsers(await fetchRaw(url, Api.userSearch)) + let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} + result = parseGraphSearch[User](await fetch(url, Api.search), after) result.query = query - if page.len == 0: - result.bottom = "2" - elif page.allCharsInSet(Digits): - result.bottom = $(parseInt(page) + 1) proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return diff --git a/src/apiutils.nim b/src/apiutils.nim index 1da971a..d1ecfa3 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-only -import httpclient, asyncdispatch, options, strutils, uri -import jsony, packedjson, zippy +import httpclient, asyncdispatch, options, strutils, uri, times, math +import jsony, packedjson, zippy, oauth1 import types, tokens, consts, parserutils, http_pool import experimental/types/common @@ -29,12 +29,30 @@ proc genParams*(pars: openArray[(string, string)] = @[]; cursor=""; else: result &= ("cursor", cursor) -proc genHeaders*(token: Token = nil): HttpHeaders = +proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = + let + encodedUrl = url.replace(",", "%2C").replace("+", "%20") + params = OAuth1Parameters( + consumerKey: consumerKey, + signatureMethod: "HMAC-SHA1", + timestamp: $int(round(epochTime())), + nonce: "0", + isIncludeVersionToHeader: true, + token: oauthToken + ) + signature = getSignature(HttpGet, encodedUrl, "", params, consumerSecret, oauthTokenSecret) + + params.signature = percentEncode(signature) + + return getOauth1RequestHeader(params)["authorization"] + +proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = + let header = getOauthHeader(url, oauthToken, oauthTokenSecret) + result = newHttpHeaders({ "connection": "keep-alive", - "authorization": auth, + "authorization": header, "content-type": "application/json", - "x-guest-token": if token == nil: "" else: token.tok, "x-twitter-active-user": "yes", "authority": "api.twitter.com", "accept-encoding": "gzip", @@ -43,24 +61,24 @@ proc genHeaders*(token: Token = nil): HttpHeaders = "DNT": "1" }) -template updateToken() = +template updateAccount() = if resp.headers.hasKey(rlRemaining): let remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) - token.setRateLimit(api, remaining, reset) + account.setRateLimit(api, remaining, reset) template fetchImpl(result, fetchBody) {.dirty.} = once: pool = HttpPool() - var token = await getToken(api) - if token.tok.len == 0: + var account = await getGuestAccount(api) + if account.oauthToken.len == 0: raise rateLimitError() try: var resp: AsyncResponse - pool.use(genHeaders(token)): + pool.use(genHeaders($url, account.oauthToken, account.oauthSecret)): template getContent = resp = await c.get($url) result = await resp.body @@ -79,19 +97,19 @@ template fetchImpl(result, fetchBody) {.dirty.} = fetchBody - release(token, used=true) + release(account, used=true) if resp.status == $Http400: raise newException(InternalError, $url) except InternalError as e: raise e except BadClientError as e: - release(token, used=true) + release(account, used=true) raise e except Exception as e: - echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url + echo "error: ", e.name, ", msg: ", e.msg, ", accountId: ", account.id, ", url: ", url if "length" notin e.msg and "descriptor" notin e.msg: - release(token, invalid=true) + release(account, invalid=true) raise rateLimitError() proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = @@ -103,12 +121,12 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = echo resp.status, ": ", body, " --- url: ", url result = newJNull() - updateToken() + updateAccount() let error = result.getError if error in {invalidToken, badToken}: echo "fetch error: ", result.getError - release(token, invalid=true) + release(account, invalid=true) raise rateLimitError() proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = @@ -117,11 +135,11 @@ proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = echo resp.status, ": ", result, " --- url: ", url result.setLen(0) - updateToken() + updateAccount() if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) if errors in {invalidToken, badToken}: echo "fetch error: ", errors - release(token, invalid=true) + release(account, invalid=true) raise rateLimitError() diff --git a/src/consts.nim b/src/consts.nim index a25f6ea..2cfd1ed 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -2,17 +2,13 @@ import uri, sequtils, strutils const - auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" + consumerKey* = "3nVuSoBZnx6U4vzUxf5w" + consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" api = parseUri("https://api.twitter.com") activate* = $(api / "1.1/guest/activate.json") - legacyUserTweets* = api / "1.1/timeline/user.json" photoRail* = api / "1.1/statuses/media_timeline.json" - userSearch* = api / "1.1/users/search.json" - tweetSearch* = api / "1.1/search/universal.json" - - # oldUserTweets* = api / "2/timeline/profile" graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" diff --git a/src/nitter.nim b/src/nitter.nim index 25a569d..4a4ec13 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -3,6 +3,7 @@ import asyncdispatch, strformat, logging from net import Port from htmlgen import a from os import getEnv +from json import parseJson import jester @@ -15,8 +16,14 @@ import routes/[ const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" -let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") -let (cfg, fullCfg) = getConfig(configPath) +let + configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") + (cfg, fullCfg) = getConfig(configPath) + + accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json") + accounts = parseJson(readFile(accountsPath)) + +initAccountPool(cfg, parseJson(readFile(accountsPath))) if not cfg.enableDebug: # Silence Jester's query warning @@ -38,8 +45,6 @@ waitFor initRedisPool(cfg) stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" stdout.flushFile -asyncCheck initTokenPool(cfg) - createUnsupportedRouter(cfg) createResolverRouter(cfg) createPrefRouter(cfg) diff --git a/src/parser.nim b/src/parser.nim index c7d8bd1..9262b28 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -29,7 +29,9 @@ proc parseUser(js: JsonNode; id=""): User = result.expandUserEntities(js) proc parseGraphUser(js: JsonNode): User = - let user = ? js{"user_result", "result"} + var user = js{"user_result", "result"} + if user.isNull: + user = ? js{"user_results", "result"} result = parseUser(user{"legacy"}) if "is_blue_verified" in user: @@ -287,169 +289,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.text.removeSuffix(" Learn more.") result.available = false -proc parseLegacyTweet(js: JsonNode): Tweet = - result = parseTweet(js, js{"card"}) - if not result.isNil and result.available: - result.user = parseUser(js{"user"}) - - if result.quote.isSome: - result.quote = some parseLegacyTweet(js{"quoted_status"}) - -proc parseTweetSearch*(js: JsonNode; after=""): Timeline = - result.beginning = after.len == 0 - - if js.kind == JNull or "modules" notin js or js{"modules"}.len == 0: - return - - for item in js{"modules"}: - with tweet, item{"status", "data"}: - let parsed = parseLegacyTweet(tweet) - - if parsed.retweet.isSome: - parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"}) - - result.content.add @[parsed] - - if result.content.len > 0: - result.bottom = $(result.content[^1][0].id - 1) - -proc parseUserTimelineTweet(tweet: JsonNode; users: TableRef[string, User]): Tweet = - result = parseTweet(tweet, tweet{"card"}) - - if result.isNil or not result.available: - return - - with user, tweet{"user"}: - let userId = user{"id_str"}.getStr - if user{"ext_is_blue_verified"}.getBool(false): - users[userId].verified = users[userId].verified or true - result.user = users[userId] - -proc parseUserTimeline*(js: JsonNode; after=""): Profile = - result = Profile(tweets: Timeline(beginning: after.len == 0)) - - if js.kind == JNull or "response" notin js or "twitter_objects" notin js: - return - - var users = newTable[string, User]() - for userId, user in js{"twitter_objects", "users"}: - users[userId] = parseUser(user) - - for entity in js{"response", "timeline"}: - let - tweetId = entity{"tweet", "id"}.getId - isPinned = entity{"tweet", "is_pinned"}.getBool(false) - - with tweet, js{"twitter_objects", "tweets", $tweetId}: - var parsed = parseUserTimelineTweet(tweet, users) - - if not parsed.isNil and parsed.available: - if parsed.quote.isSome: - parsed.quote = some parseUserTimelineTweet(tweet{"quoted_status"}, users) - - if parsed.retweet.isSome: - let retweet = parseUserTimelineTweet(tweet{"retweeted_status"}, users) - if retweet.quote.isSome: - retweet.quote = some parseUserTimelineTweet(tweet{"retweeted_status", "quoted_status"}, users) - parsed.retweet = some retweet - - if isPinned: - parsed.pinned = true - result.pinned = some parsed - else: - result.tweets.content.add parsed - - result.tweets.bottom = js{"response", "cursor", "bottom"}.getStr - -# proc finalizeTweet(global: GlobalObjects; id: string): Tweet = -# let intId = if id.len > 0: parseBiggestInt(id) else: 0 -# result = global.tweets.getOrDefault(id, Tweet(id: intId)) - -# if result.quote.isSome: -# let quote = get(result.quote).id -# if $quote in global.tweets: -# result.quote = some global.tweets[$quote] -# else: -# result.quote = some Tweet() - -# if result.retweet.isSome: -# let rt = get(result.retweet).id -# if $rt in global.tweets: -# result.retweet = some finalizeTweet(global, $rt) -# else: -# result.retweet = some Tweet() - -# proc parsePin(js: JsonNode; global: GlobalObjects): Tweet = -# let pin = js{"pinEntry", "entry", "entryId"}.getStr -# if pin.len == 0: return - -# let id = pin.getId -# if id notin global.tweets: return - -# global.tweets[id].pinned = true -# return finalizeTweet(global, id) - -# proc parseGlobalObjects(js: JsonNode): GlobalObjects = -# result = GlobalObjects() -# let -# tweets = ? js{"globalObjects", "tweets"} -# users = ? js{"globalObjects", "users"} - -# for k, v in users: -# result.users[k] = parseUser(v, k) - -# for k, v in tweets: -# var tweet = parseTweet(v, v{"card"}) -# if tweet.user.id in result.users: -# tweet.user = result.users[tweet.user.id] -# result.tweets[k] = tweet - -# proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) = -# if js.kind != JArray or js.len == 0: -# return - -# for i in js: -# if res.tweets.beginning and i{"pinEntry"}.notNull: -# with pin, parsePin(i, global): -# res.pinned = some pin - -# with r, i{"replaceEntry", "entry"}: -# if "top" in r{"entryId"}.getStr: -# res.tweets.top = r.getCursor -# elif "bottom" in r{"entryId"}.getStr: -# res.tweets.bottom = r.getCursor - -# proc parseTimeline*(js: JsonNode; after=""): Profile = -# result = Profile(tweets: Timeline(beginning: after.len == 0)) -# let global = parseGlobalObjects(? js) - -# let instructions = ? js{"timeline", "instructions"} -# if instructions.len == 0: return - -# result.parseInstructions(global, instructions) - -# var entries: JsonNode -# for i in instructions: -# if "addEntries" in i: -# entries = i{"addEntries", "entries"} - -# for e in ? entries: -# let entry = e{"entryId"}.getStr -# if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry: -# let tweet = finalizeTweet(global, e.getEntryId) -# if not tweet.available: continue -# result.tweets.content.add tweet -# elif "cursor-top" in entry: -# result.tweets.top = e.getCursor -# elif "cursor-bottom" in entry: -# result.tweets.bottom = e.getCursor -# elif entry.startsWith("sq-cursor"): -# with cursor, e{"content", "operation", "cursor"}: -# if cursor{"cursorType"}.getStr == "Bottom": -# result.tweets.bottom = cursor{"value"}.getStr -# else: -# result.tweets.top = cursor{"value"}.getStr - proc parsePhotoRail*(js: JsonNode): PhotoRail = with error, js{"error"}: if error.getStr == "Not authorized.": @@ -597,8 +436,8 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = tweet.id = parseBiggestInt(entryId) result.pinned = some tweet -proc parseGraphSearch*(js: JsonNode; after=""): Timeline = - result = Timeline(beginning: after.len == 0) +proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = + result = Result[T](beginning: after.len == 0) let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} if instructions.len == 0: @@ -607,15 +446,21 @@ proc parseGraphSearch*(js: JsonNode; after=""): Timeline = for instruction in instructions: let typ = instruction{"type"}.getStr if typ == "TimelineAddEntries": - for e in instructions[0]{"entries"}: + for e in instruction{"entries"}: let entryId = e{"entryId"}.getStr - if entryId.startsWith("tweet"): - with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) - result.content.add tweet - elif entryId.startsWith("cursor-bottom"): + when T is Tweets: + if entryId.startsWith("tweet"): + with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetRes) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.content.add tweet + elif T is User: + if entryId.startsWith("user"): + with userRes, e{"content", "itemContent"}: + result.content.add parseGraphUser(userRes) + + if entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 89161be..2387a42 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -147,15 +147,15 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = if result.len > 0 and user.id.len > 0: await all(cacheUserId(result, user.id), cache(user)) -proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = - if id == 0: return - let tweet = await get(id.tweetKey) - if tweet != redisNil: - tweet.deserialize(Tweet) - else: - result = await getGraphTweetResult($id) - if not result.isNil: - await cache(result) +# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = +# if id == 0: return +# let tweet = await get(id.tweetKey) +# if tweet != redisNil: +# tweet.deserialize(Tweet) +# else: +# result = await getGraphTweetResult($id) +# if not result.isNil: +# await cache(result) proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return diff --git a/src/routes/rss.nim b/src/routes/rss.nim index d378396..6c77992 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -27,7 +27,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. else: var q = query q.fromUser = names - profile.tweets = await getTweetSearch(q, after) + profile.tweets = await getGraphTweetSearch(q, after) # this is kinda dumb profile.user = User( username: name, @@ -76,7 +76,7 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "Search") - let tweets = await getTweetSearch(query, cursor) + let tweets = await getGraphTweetSearch(query, cursor) rss.cursor = tweets.bottom rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) diff --git a/src/routes/search.nim b/src/routes/search.nim index c270df5..e9f991d 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -29,13 +29,13 @@ proc createSearchRouter*(cfg: Config) = redirect("/" & q) var users: Result[User] try: - users = await getUserSearch(query, getCursor()) + users = await getGraphUserSearch(query, getCursor()) except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) of tweets: let - tweets = await getTweetSearch(query, getCursor()) + tweets = await getGraphTweetSearch(query, getCursor()) rss = "/search/rss?" & genQueryUrl(query) resp renderMain(renderTweetSearch(tweets, prefs, getPath()), request, cfg, prefs, title, rss=rss) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 8d02b68..3568ab7 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -53,10 +53,10 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; result = case query.kind - of posts: await getUserTimeline(userId, after) + of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) - else: Profile(tweets: await getTweetSearch(query, after)) + else: Profile(tweets: await getGraphTweetSearch(query, after)) result.user = await user result.photoRail = await rail @@ -67,7 +67,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = if query.fromUser.len != 1: let - timeline = await getTweetSearch(query, after) + timeline = await getGraphTweetSearch(query, after) html = renderTweetSearch(timeline, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) @@ -122,7 +122,7 @@ proc createTimelineRouter*(cfg: Config) = # used for the infinite scroll feature if @"scroll".len > 0: if query.fromUser.len != 1: - var timeline = await getTweetSearch(query, after) + var timeline = await getGraphTweetSearch(query, after) if timeline.content.len == 0: resp Http404 timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) diff --git a/src/tokens.nim b/src/tokens.nim index decf228..71a7abd 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -1,23 +1,16 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, httpclient, times, sequtils, json, random -import strutils, tables -import types, consts +import asyncdispatch, times, json, random, strutils, tables +import types -const - maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions - maxLastUse = 1.hours # if a token is unused for 60 minutes, it expires - maxAge = 2.hours + 55.minutes # tokens expire after 3 hours - failDelay = initDuration(minutes=30) +# max requests at a time per account to avoid race conditions +const maxConcurrentReqs = 5 var - tokenPool: seq[Token] - lastFailed: Time + accountPool: seq[GuestAccount] enableLogging = false -let headers = newHttpHeaders({"authorization": auth}) - template log(str) = - if enableLogging: echo "[tokens] ", str + if enableLogging: echo "[accounts] ", str proc getPoolJson*(): JsonNode = var @@ -26,141 +19,94 @@ proc getPoolJson*(): JsonNode = totalPending = 0 reqsPerApi: Table[string, int] - for token in tokenPool: - totalPending.inc(token.pending) - list[token.tok] = %*{ + for account in accountPool: + totalPending.inc(account.pending) + list[account.id] = %*{ "apis": newJObject(), - "pending": token.pending, - "init": $token.init, - "lastUse": $token.lastUse + "pending": account.pending, } - for api in token.apis.keys: - list[token.tok]["apis"][$api] = %token.apis[api] + for api in account.apis.keys: + list[account.id]["apis"][$api] = %account.apis[api].remaining let maxReqs = case api - of Api.search: 100000 + of Api.search: 50 of Api.photoRail: 180 - of Api.timeline: 187 - of Api.userTweets, Api.userTimeline: 300 - of Api.userTweetsAndReplies, Api.userRestId, - Api.userScreenName, Api.tweetDetail, Api.tweetResult, - Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia: 500 - of Api.userSearch: 900 - reqs = maxReqs - token.apis[api].remaining + of Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, + Api.userRestId, Api.userScreenName, + Api.tweetDetail, Api.tweetResult, + Api.list, Api.listTweets, Api.listMembers, Api.listBySlug: 500 + reqs = maxReqs - account.apis[api].remaining reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs totalReqs.inc(reqs) return %*{ - "amount": tokenPool.len, + "amount": accountPool.len, "requests": totalReqs, "pending": totalPending, "apis": reqsPerApi, - "tokens": list + "accounts": list } proc rateLimitError*(): ref RateLimitError = newException(RateLimitError, "rate limited") -proc fetchToken(): Future[Token] {.async.} = - if getTime() - lastFailed < failDelay: - raise rateLimitError() - - let client = newAsyncHttpClient(headers=headers) - - try: - let - resp = await client.postContent(activate) - tokNode = parseJson(resp)["guest_token"] - tok = tokNode.getStr($(tokNode.getInt)) - time = getTime() - - return Token(tok: tok, init: time, lastUse: time) - except Exception as e: - echo "[tokens] fetching token failed: ", e.msg - if "Try again" notin e.msg: - echo "[tokens] fetching tokens paused, resuming in 30 minutes" - lastFailed = getTime() - finally: - client.close() - -proc expired(token: Token): bool = - let time = getTime() - token.init < time - maxAge or token.lastUse < time - maxLastUse - -proc isLimited(token: Token; api: Api): bool = - if token.isNil or token.expired: +proc isLimited(account: GuestAccount; api: Api): bool = + if account.isNil: return true - if api in token.apis: - let limit = token.apis[api] + if api in account.apis: + let limit = account.apis[api] return (limit.remaining <= 10 and limit.reset > epochTime().int) else: return false -proc isReady(token: Token; api: Api): bool = - not (token.isNil or token.pending > maxConcurrentReqs or token.isLimited(api)) +proc isReady(account: GuestAccount; api: Api): bool = + not (account.isNil or account.pending > maxConcurrentReqs or account.isLimited(api)) -proc release*(token: Token; used=false; invalid=false) = - if token.isNil: return - if invalid or token.expired: - if invalid: log "discarding invalid token" - elif token.expired: log "discarding expired token" +proc release*(account: GuestAccount; used=false; invalid=false) = + if account.isNil: return + if invalid: + log "discarding invalid account: " & account.id - let idx = tokenPool.find(token) - if idx > -1: tokenPool.delete(idx) + let idx = accountPool.find(account) + if idx > -1: accountPool.delete(idx) elif used: - dec token.pending - token.lastUse = getTime() + dec account.pending -proc getToken*(api: Api): Future[Token] {.async.} = - for i in 0 ..< tokenPool.len: +proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} = + for i in 0 ..< accountPool.len: if result.isReady(api): break release(result) - result = tokenPool.sample() + result = accountPool.sample() - if not result.isReady(api): - release(result) - result = await fetchToken() - log "added new token to pool" - tokenPool.add result - - if not result.isNil: + if not result.isNil and result.isReady(api): inc result.pending else: + log "no accounts available for API: " & $api raise rateLimitError() -proc setRateLimit*(token: Token; api: Api; remaining, reset: int) = +proc setRateLimit*(account: GuestAccount; api: Api; remaining, reset: int) = # avoid undefined behavior in race conditions - if api in token.apis: - let limit = token.apis[api] + if api in account.apis: + let limit = account.apis[api] if limit.reset >= reset and limit.remaining < remaining: return + if limit.reset == reset and limit.remaining >= remaining: + account.apis[api].remaining = remaining + return - token.apis[api] = RateLimit(remaining: remaining, reset: reset) + account.apis[api] = RateLimit(remaining: remaining, reset: reset) -proc poolTokens*(amount: int) {.async.} = - var futs: seq[Future[Token]] - for i in 0 ..< amount: - futs.add fetchToken() - - for token in futs: - var newToken: Token - - try: newToken = await token - except: discard - - if not newToken.isNil: - log "added new token to pool" - tokenPool.add newToken - -proc initTokenPool*(cfg: Config) {.async.} = +proc initAccountPool*(cfg: Config; accounts: JsonNode) = enableLogging = cfg.enableDebug - while true: - if tokenPool.countIt(not it.isLimited(Api.userTimeline)) < cfg.minTokens: - await poolTokens(min(4, cfg.minTokens - tokenPool.len)) - await sleepAsync(2000) + for account in accounts: + accountPool.add GuestAccount( + id: account{"user", "id_str"}.getStr, + oauthToken: account{"oauth_token"}.getStr, + oauthSecret: account{"oauth_token_secret"}.getStr, + ) diff --git a/src/types.nim b/src/types.nim index 1a47d25..2a553dd 100644 --- a/src/types.nim +++ b/src/types.nim @@ -17,11 +17,8 @@ type Api* {.pure.} = enum tweetDetail tweetResult - timeline - userTimeline photoRail search - userSearch list listBySlug listMembers @@ -36,9 +33,11 @@ type remaining*: int reset*: int - Token* = ref object - tok*: string - init*: Time + GuestAccount* = ref object + id*: string + oauthToken*: string + oauthSecret*: string + # init*: Time lastUse*: Time pending*: int apis*: Table[Api, RateLimit] From bbd68e684071e8b26fca89587db48f12417a9bf0 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 19 Aug 2023 01:13:36 +0200 Subject: [PATCH 029/309] Filter out account limits that already reset --- src/tokens.nim | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tokens.nim b/src/tokens.nim index 71a7abd..401dc05 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -19,6 +19,8 @@ proc getPoolJson*(): JsonNode = totalPending = 0 reqsPerApi: Table[string, int] + let now = epochTime() + for account in accountPool: totalPending.inc(account.pending) list[account.id] = %*{ @@ -27,6 +29,9 @@ proc getPoolJson*(): JsonNode = } for api in account.apis.keys: + if (now.int - account.apis[api].reset) / 60 > 15: + continue + list[account.id]["apis"][$api] = %account.apis[api].remaining let From 3d8858f0d86d09ce815bc78db417290557f23908 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 20 Aug 2023 11:56:42 +0200 Subject: [PATCH 030/309] Track rate limits, reset after 24 hours --- src/apiutils.nim | 12 +++++++++++- src/tokens.nim | 26 ++++++++++++++++++++------ src/types.nim | 2 ++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index d1ecfa3..54e6777 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import httpclient, asyncdispatch, options, strutils, uri, times, math +import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import jsony, packedjson, zippy, oauth1 import types, tokens, consts, parserutils, http_pool import experimental/types/common @@ -129,6 +129,16 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = release(account, invalid=true) raise rateLimitError() + if body.startsWith("{\"errors"): + let errors = body.fromJson(Errors) + if errors in {invalidToken, badToken}: + echo "fetch error: ", errors + release(account, invalid=true) + raise rateLimitError() + elif errors in {rateLimited}: + account.apis[api].limited = true + echo "rate limited, api: ", $api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id + proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = fetchImpl result: if not (result.startsWith('{') or result.startsWith('[')): diff --git a/src/tokens.nim b/src/tokens.nim index 401dc05..45aa895 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -3,7 +3,9 @@ import asyncdispatch, times, json, random, strutils, tables import types # max requests at a time per account to avoid race conditions -const maxConcurrentReqs = 5 +const + maxConcurrentReqs = 5 + dayInSeconds = 24 * 60 * 60 var accountPool: seq[GuestAccount] @@ -19,7 +21,7 @@ proc getPoolJson*(): JsonNode = totalPending = 0 reqsPerApi: Table[string, int] - let now = epochTime() + let now = epochTime().int for account in accountPool: totalPending.inc(account.pending) @@ -29,10 +31,17 @@ proc getPoolJson*(): JsonNode = } for api in account.apis.keys: - if (now.int - account.apis[api].reset) / 60 > 15: - continue + let obj = %*{} + if account.apis[api].limited: + obj["limited"] = %true - list[account.id]["apis"][$api] = %account.apis[api].remaining + if account.apis[api].reset > now.int: + obj["remaining"] = %account.apis[api].remaining + + list[account.id]["apis"][$api] = obj + + if "remaining" notin obj: + continue let maxReqs = @@ -65,7 +74,12 @@ proc isLimited(account: GuestAccount; api: Api): bool = if api in account.apis: let limit = account.apis[api] - return (limit.remaining <= 10 and limit.reset > epochTime().int) + + if limit.limited and (epochTime().int - limit.limitedAt) > dayInSeconds: + account.apis[api].limited = false + echo "account limit reset, api: ", api, ", id: ", account.id + + return limit.limited or (limit.remaining <= 10 and limit.reset > epochTime().int) else: return false diff --git a/src/types.nim b/src/types.nim index 2a553dd..33d0cda 100644 --- a/src/types.nim +++ b/src/types.nim @@ -32,6 +32,8 @@ type RateLimit* = object remaining*: int reset*: int + limited*: bool + limitedAt*: int GuestAccount* = ref object id*: string From e8b5cbef7b984c527675a4df8fe2c7b4d841b13c Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 20 Aug 2023 12:31:08 +0200 Subject: [PATCH 031/309] Add missing limitedAt assignment --- src/apiutils.nim | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apiutils.nim b/src/apiutils.nim index 54e6777..6e333e7 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -137,6 +137,7 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = raise rateLimitError() elif errors in {rateLimited}: account.apis[api].limited = true + account.apis[api].limitedAt = epochTime().int echo "rate limited, api: ", $api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = From 51714b5ad2f8992972f43fd9cb2178aba2729f39 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 21 Aug 2023 11:25:27 +0200 Subject: [PATCH 032/309] Add guest accounts variable to GitHub action --- .github/workflows/run-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 140b6bf..37979cb 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -40,6 +40,9 @@ jobs: nimble md nimble scss - name: Run tests + env: + GUEST_ACCOUNTS: ${{ secrets.GUEST_ACCOUNTS }} run: | + echo $GUEST_ACCOUNTS > ./guest_accounts.json ./nitter & pytest -n4 tests From c3d9441370e9740a02834dbf1da849263a9075c3 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 21 Aug 2023 14:49:50 +0200 Subject: [PATCH 033/309] Unify some guest account logs --- src/apiutils.nim | 2 +- src/tokens.nim | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 6e333e7..453b36a 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -138,7 +138,7 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = elif errors in {rateLimited}: account.apis[api].limited = true account.apis[api].limitedAt = epochTime().int - echo "rate limited, api: ", $api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id + echo "[accounts] rate limited, api: ", api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = fetchImpl result: diff --git a/src/tokens.nim b/src/tokens.nim index 45aa895..a44866f 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -77,7 +77,7 @@ proc isLimited(account: GuestAccount; api: Api): bool = if limit.limited and (epochTime().int - limit.limitedAt) > dayInSeconds: account.apis[api].limited = false - echo "account limit reset, api: ", api, ", id: ", account.id + log "resetting limit, api: ", api, ", id: ", account.id return limit.limited or (limit.remaining <= 10 and limit.reset > epochTime().int) else: From 12504bcffed59efe626b90a574b27857f7450515 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 21 Aug 2023 18:12:06 +0200 Subject: [PATCH 034/309] Fix compilation error --- src/tokens.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tokens.nim b/src/tokens.nim index a44866f..d1f1e68 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -77,7 +77,7 @@ proc isLimited(account: GuestAccount; api: Api): bool = if limit.limited and (epochTime().int - limit.limitedAt) > dayInSeconds: account.apis[api].limited = false - log "resetting limit, api: ", api, ", id: ", account.id + log "resetting limit, api: " & $api & ", id: " & $account.id return limit.limited or (limit.remaining <= 10 and limit.reset > epochTime().int) else: From 30bdf3a14e900c653f1c277c5e0221cab846e1de Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 22 Aug 2023 01:32:09 +0200 Subject: [PATCH 035/309] Reduce max concurrent pending requests per account --- src/tokens.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tokens.nim b/src/tokens.nim index d1f1e68..f16a816 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -1,10 +1,10 @@ -# SPDX-License-Identifier: AGPL-3.0-only +#i hate begging for this too em SPDX-License-Identifier: AGPL-3.0-only import asyncdispatch, times, json, random, strutils, tables import types # max requests at a time per account to avoid race conditions const - maxConcurrentReqs = 5 + maxConcurrentReqs = 2 dayInSeconds = 24 * 60 * 60 var From 5c08e6a774f428b4193a33e0400729c279ded07c Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 22 Aug 2023 02:27:44 +0200 Subject: [PATCH 036/309] Fix compilation on older versions of Nim --- src/parserutils.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/parserutils.nim b/src/parserutils.nim index c65052e..7cf696e 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -36,7 +36,8 @@ template with*(ident, value, body): untyped = template with*(ident; value: JsonNode; body): untyped = if true: let ident {.inject.} = value - if value.notNull: body + # value.notNull causes a compilation error for versions < 1.6.14 + if notNull(value): body template getCursor*(js: JsonNode): string = js{"content", "operation", "cursor", "value"}.getStr From 6e8744943f9c47cbc7150785a86fa05a18d7b98b Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 22 Aug 2023 03:43:18 +0200 Subject: [PATCH 037/309] Tweak /.tokens, add amount of limited accounts --- src/tokens.nim | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/tokens.nim b/src/tokens.nim index f16a816..5582a51 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -19,6 +19,7 @@ proc getPoolJson*(): JsonNode = list = newJObject() totalReqs = 0 totalPending = 0 + totalLimited = 0 reqsPerApi: Table[string, int] let now = epochTime().int @@ -31,34 +32,40 @@ proc getPoolJson*(): JsonNode = } for api in account.apis.keys: - let obj = %*{} - if account.apis[api].limited: - obj["limited"] = %true + let + apiStatus = account.apis[api] + obj = %*{} - if account.apis[api].reset > now.int: - obj["remaining"] = %account.apis[api].remaining + if apiStatus.limited: + obj["limited"] = %true + inc totalLimited + + if apiStatus.reset > now.int: + obj["remaining"] = %apiStatus.remaining + + if "remaining" notin obj and not apiStatus.limited: + continue list[account.id]["apis"][$api] = obj - if "remaining" notin obj: - continue - let maxReqs = case api of Api.search: 50 + of Api.tweetDetail: 150 of Api.photoRail: 180 of Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, - Api.userRestId, Api.userScreenName, - Api.tweetDetail, Api.tweetResult, + Api.userRestId, Api.userScreenName, Api.tweetResult, Api.list, Api.listTweets, Api.listMembers, Api.listBySlug: 500 - reqs = maxReqs - account.apis[api].remaining + of Api.userSearch: 900 + reqs = maxReqs - apiStatus.remaining reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs totalReqs.inc(reqs) return %*{ "amount": accountPool.len, + "limited": totalLimited, "requests": totalReqs, "pending": totalPending, "apis": reqsPerApi, From 8df5256c1dd3970bcbb71cfa7753c3b50d59287e Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 22 Aug 2023 03:44:11 +0200 Subject: [PATCH 038/309] Switch back to old user search endpoint --- src/api.nim | 28 ++++++++++++---------------- src/consts.nim | 2 ++ src/experimental/parser/user.nim | 2 +- src/experimental/types/user.nim | 1 + src/parser.nim | 26 ++++++++++---------------- src/routes/search.nim | 2 +- src/types.nim | 1 + 7 files changed, 28 insertions(+), 34 deletions(-) diff --git a/src/api.nim b/src/api.nim index d6a4564..4ac999c 100644 --- a/src/api.nim +++ b/src/api.nim @@ -112,29 +112,25 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = if after.len > 0: variables["cursor"] = % after let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} - result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) + result = parseGraphSearch(await fetch(url, Api.search), after) result.query = query -proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = +proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = if query.text.len == 0: return Result[User](query: query, beginning: true) - var - variables = %*{ - "rawQuery": query.text, - "count": 20, - "product": "People", - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false - } - if after.len > 0: - variables["cursor"] = % after - result.beginning = false + let + page = if page.len == 0: "1" else: page + url = userSearch ? genParams({"q": query.text, "skip_status": "1", "page": page}) + js = await fetchRaw(url, Api.userSearch) + + result = parseUsers(js) - let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} - result = parseGraphSearch[User](await fetch(url, Api.search), after) result.query = query + if page.len == 0: + result.bottom = "2" + elif page.allCharsInSet(Digits): + result.bottom = $(parseInt(page) + 1) proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return diff --git a/src/consts.nim b/src/consts.nim index 2cfd1ed..8bf6422 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -9,6 +9,7 @@ const activate* = $(api / "1.1/guest/activate.json") photoRail* = api / "1.1/statuses/media_timeline.json" + userSearch* = api / "1.1/users/search.json" graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" @@ -34,6 +35,7 @@ const "include_user_entities": "1", "include_ext_reply_count": "1", "include_ext_is_blue_verified": "1", + #"include_ext_verified_type": "1", "include_ext_media_color": "0", "cards_platform": "Web-13", "tweet_mode": "extended", diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index b4d710f..5962a87 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -56,7 +56,7 @@ proc toUser*(raw: RawUser): User = tweets: raw.statusesCount, likes: raw.favouritesCount, media: raw.mediaCount, - verified: raw.verified, + verified: raw.verified or raw.extIsBlueVerified, protected: raw.protected, joinDate: parseTwitterDate(raw.createdAt), banner: getBanner(raw), diff --git a/src/experimental/types/user.nim b/src/experimental/types/user.nim index 1c8a5c3..39331a0 100644 --- a/src/experimental/types/user.nim +++ b/src/experimental/types/user.nim @@ -16,6 +16,7 @@ type statusesCount*: int mediaCount*: int verified*: bool + extIsBlueVerified*: bool protected*: bool profileLinkColor*: string profileBannerUrl*: string diff --git a/src/parser.nim b/src/parser.nim index 9262b28..03242c1 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, options, times, math, tables +import strutils, options, times, math import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard @@ -436,8 +436,8 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = tweet.id = parseBiggestInt(entryId) result.pinned = some tweet -proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = - result = Result[T](beginning: after.len == 0) +proc parseGraphSearch*(js: JsonNode; after=""): Timeline = + result = Timeline(beginning: after.len == 0) let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} if instructions.len == 0: @@ -448,19 +448,13 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = if typ == "TimelineAddEntries": for e in instruction{"entries"}: let entryId = e{"entryId"}.getStr - when T is Tweets: - if entryId.startsWith("tweet"): - with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetRes) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) - result.content.add tweet - elif T is User: - if entryId.startsWith("user"): - with userRes, e{"content", "itemContent"}: - result.content.add parseGraphUser(userRes) - - if entryId.startsWith("cursor-bottom"): + if entryId.startsWith("tweet"): + with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetRes) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.content.add tweet + elif entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): diff --git a/src/routes/search.nim b/src/routes/search.nim index e9f991d..676229e 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -29,7 +29,7 @@ proc createSearchRouter*(cfg: Config) = redirect("/" & q) var users: Result[User] try: - users = await getGraphUserSearch(query, getCursor()) + users = await getUserSearch(query, getCursor()) except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) diff --git a/src/types.nim b/src/types.nim index 33d0cda..fcb24c0 100644 --- a/src/types.nim +++ b/src/types.nim @@ -19,6 +19,7 @@ type tweetResult photoRail search + userSearch list listBySlug listMembers From 45808361af63e848ce322a27190d3ed7aba0723a Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 22 Aug 2023 04:45:49 +0200 Subject: [PATCH 039/309] Fix tweetDetail stats --- src/tokens.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tokens.nim b/src/tokens.nim index 5582a51..a3ed78a 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -52,10 +52,10 @@ proc getPoolJson*(): JsonNode = maxReqs = case api of Api.search: 50 - of Api.tweetDetail: 150 of Api.photoRail: 180 of Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, - Api.userRestId, Api.userScreenName, Api.tweetResult, + Api.userRestId, Api.userScreenName, + Api.tweetResult, Api.tweetDetail, Api.list, Api.listTweets, Api.listMembers, Api.listBySlug: 500 of Api.userSearch: 900 reqs = maxReqs - apiStatus.remaining From a3e11e3272ebdd6118cd818591530ac8be5ae491 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 23 Aug 2023 10:14:44 +0200 Subject: [PATCH 040/309] Switch to using typeahead for user search --- src/api.nim | 12 +++--------- src/consts.nim | 2 +- src/experimental/parser/user.nim | 7 +++++++ src/experimental/types/user.nim | 3 +++ src/routes/search.nim | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/api.nim b/src/api.nim index 4ac999c..a7efbd0 100644 --- a/src/api.nim +++ b/src/api.nim @@ -115,22 +115,16 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = result = parseGraphSearch(await fetch(url, Api.search), after) result.query = query -proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = +proc getUserSearch*(query: Query): Future[Result[User]] {.async.} = if query.text.len == 0: return Result[User](query: query, beginning: true) let - page = if page.len == 0: "1" else: page - url = userSearch ? genParams({"q": query.text, "skip_status": "1", "page": page}) + url = userSearch ? genParams({"q": query.text, "result_type": "users"}) js = await fetchRaw(url, Api.userSearch) - result = parseUsers(js) - + result = parseTypeahead(js) result.query = query - if page.len == 0: - result.bottom = "2" - elif page.allCharsInSet(Digits): - result.bottom = $(parseInt(page) + 1) proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return diff --git a/src/consts.nim b/src/consts.nim index 8bf6422..1bb950e 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -9,7 +9,7 @@ const activate* = $(api / "1.1/guest/activate.json") photoRail* = api / "1.1/statuses/media_timeline.json" - userSearch* = api / "1.1/users/search.json" + userSearch* = api / "1.1/search/typeahead.json" graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 5962a87..400e740 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -85,3 +85,10 @@ proc parseUsers*(json: string; after=""): Result[User] = let raw = json.fromJson(seq[RawUser]) for user in raw: result.content.add user.toUser + +proc parseTypeahead*(json: string): Result[User] = + result = Result[User](beginning: true) + + let raw = json.fromJson(Typeahead) + for user in raw.users: + result.content.add user.toUser diff --git a/src/experimental/types/user.nim b/src/experimental/types/user.nim index 39331a0..7d34f7b 100644 --- a/src/experimental/types/user.nim +++ b/src/experimental/types/user.nim @@ -42,3 +42,6 @@ type Color* = object red*, green*, blue*: int + + Typeahead* = object + users*: seq[RawUser] diff --git a/src/routes/search.nim b/src/routes/search.nim index 676229e..e0a888c 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -29,7 +29,7 @@ proc createSearchRouter*(cfg: Config) = redirect("/" & q) var users: Result[User] try: - users = await getUserSearch(query, getCursor()) + users = await getUserSearch(query) except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) From 88b005c9da726af3f0858339da60949b42278744 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 23 Aug 2023 19:31:40 +0200 Subject: [PATCH 041/309] Revert "Switch to using typeahead for user search" This reverts commit a3e11e3272ebdd6118cd818591530ac8be5ae491. --- src/api.nim | 12 +++++++++--- src/consts.nim | 2 +- src/experimental/parser/user.nim | 7 ------- src/experimental/types/user.nim | 3 --- src/routes/search.nim | 2 +- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/api.nim b/src/api.nim index a7efbd0..4ac999c 100644 --- a/src/api.nim +++ b/src/api.nim @@ -115,16 +115,22 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = result = parseGraphSearch(await fetch(url, Api.search), after) result.query = query -proc getUserSearch*(query: Query): Future[Result[User]] {.async.} = +proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = if query.text.len == 0: return Result[User](query: query, beginning: true) let - url = userSearch ? genParams({"q": query.text, "result_type": "users"}) + page = if page.len == 0: "1" else: page + url = userSearch ? genParams({"q": query.text, "skip_status": "1", "page": page}) js = await fetchRaw(url, Api.userSearch) - result = parseTypeahead(js) + result = parseUsers(js) + result.query = query + if page.len == 0: + result.bottom = "2" + elif page.allCharsInSet(Digits): + result.bottom = $(parseInt(page) + 1) proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return diff --git a/src/consts.nim b/src/consts.nim index 1bb950e..8bf6422 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -9,7 +9,7 @@ const activate* = $(api / "1.1/guest/activate.json") photoRail* = api / "1.1/statuses/media_timeline.json" - userSearch* = api / "1.1/search/typeahead.json" + userSearch* = api / "1.1/users/search.json" graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 400e740..5962a87 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -85,10 +85,3 @@ proc parseUsers*(json: string; after=""): Result[User] = let raw = json.fromJson(seq[RawUser]) for user in raw: result.content.add user.toUser - -proc parseTypeahead*(json: string): Result[User] = - result = Result[User](beginning: true) - - let raw = json.fromJson(Typeahead) - for user in raw.users: - result.content.add user.toUser diff --git a/src/experimental/types/user.nim b/src/experimental/types/user.nim index 7d34f7b..39331a0 100644 --- a/src/experimental/types/user.nim +++ b/src/experimental/types/user.nim @@ -42,6 +42,3 @@ type Color* = object red*, green*, blue*: int - - Typeahead* = object - users*: seq[RawUser] diff --git a/src/routes/search.nim b/src/routes/search.nim index e0a888c..676229e 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -29,7 +29,7 @@ proc createSearchRouter*(cfg: Config) = redirect("/" & q) var users: Result[User] try: - users = await getUserSearch(query) + users = await getUserSearch(query, getCursor()) except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) From ae9fa02bf5f8459e59f0f18120fee9f06404aef5 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 25 Aug 2023 16:28:30 +0200 Subject: [PATCH 042/309] Switch to TweetDetail for tweets --- src/consts.nim | 10 +++++++--- src/parser.nim | 25 +++++++++++++++---------- src/tokens.nim | 3 ++- tests/test_card.py | 10 +++++----- tests/test_timeline.py | 2 +- tests/test_tweet_media.py | 2 +- 6 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/consts.nim b/src/consts.nim index 8bf6422..96cea47 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -17,7 +17,7 @@ const graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2" graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2" graphUserMedia* = graphql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" - graphTweet* = graphql / "83h5UyHZ9wEKBVzALX8R_g/ConversationTimelineV2" + graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail" graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" @@ -89,8 +89,12 @@ const tweetVariables* = """{ "focalTweetId": "$1", $2 - "includeHasBirdwatchNotes": false -}""" + "includeHasBirdwatchNotes": false, + "includePromotedContent": false, + "withBirdwatchNotes": false, + "withVoice": false, + "withV2Timeline": true +}""".replace(" ", "").replace("\n", "") # oldUserTweetsVariables* = """{ # "userId": "$1", $2 diff --git a/src/parser.nim b/src/parser.nim index 03242c1..d5190a3 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -324,7 +324,7 @@ proc parseGraphTweet(js: JsonNode): Tweet = of "TweetWithVisibilityResults": return parseGraphTweet(js{"tweet"}) - var jsCard = copy(js{"tweet_card", "legacy"}) + var jsCard = copy(js{"card", "legacy"}) if jsCard.kind != JNull: var values = newJObject() for val in jsCard["binding_values"]: @@ -342,7 +342,6 @@ proc parseGraphTweet(js: JsonNode): Tweet = result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = - let thread = js{"content", "items"} for t in js{"content", "items"}: let entryId = t{"entryId"}.getStr if "cursor-showmore" in entryId: @@ -350,11 +349,16 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = result.thread.cursor = cursor.getStr result.thread.hasMore = true elif "tweet" in entryId: - let tweet = parseGraphTweet(t{"item", "content", "tweetResult", "result"}) - result.thread.content.add tweet + let + isLegacy = t{"item"}.hasKey("itemContent") + (contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results") + else: ("content", "tweetResult") - if t{"item", "content", "tweetDisplayType"}.getStr == "SelfThread": - result.self = true + with content, t{"item", contentKey}: + result.thread.content.add parseGraphTweet(content{resultKey, "result"}) + + if content{"tweetDisplayType"}.getStr == "SelfThread": + result.self = true proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: @@ -363,14 +367,14 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet = proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) - let instructions = ? js{"data", "timeline_response", "instructions"} + let instructions = ? js{"data", "threaded_conversation_with_injections_v2", "instructions"} if instructions.len == 0: return for e in instructions[0]{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): - with tweetResult, e{"content", "content", "tweetResult", "result"}: + with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: let tweet = parseGraphTweet(tweetResult) if not tweet.available: @@ -385,7 +389,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = let tweet = Tweet( id: parseBiggestInt(id), available: false, - text: e{"content", "content", "tombstoneInfo", "richText"}.getTombstone + text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone ) if id == tweetId: @@ -397,9 +401,10 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if self: result.after = thread else: + echo "adding thread: ", thread.content.len result.replies.content.add thread elif entryId.startsWith("cursor-bottom"): - result.replies.bottom = e{"content", "content", "value"}.getStr + result.replies.bottom = e{"content", "itemContent", "value"}.getStr proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) diff --git a/src/tokens.nim b/src/tokens.nim index a3ed78a..bb9696c 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -52,10 +52,11 @@ proc getPoolJson*(): JsonNode = maxReqs = case api of Api.search: 50 + of Api.tweetDetail: 150 of Api.photoRail: 180 of Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, Api.userRestId, Api.userScreenName, - Api.tweetResult, Api.tweetDetail, + Api.tweetResult, Api.list, Api.listTweets, Api.listMembers, Api.listBySlug: 500 of Api.userSearch: 900 reqs = maxReqs - apiStatus.remaining diff --git a/tests/test_card.py b/tests/test_card.py index f84ddca..733bd40 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -13,11 +13,6 @@ card = [ 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', 'gist.github.com', True], - ['FluentAI/status/1116417904831029248', - 'Amazon’s Alexa isn’t just AI — thousands of humans are listening', - 'One of the only ways to improve Alexa is to have human beings check it for errors', - 'theverge.com', True], - ['nim_lang/status/1082989146040340480', 'Nim in 2018: A short recap', 'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.', @@ -25,6 +20,11 @@ card = [ ] no_thumb = [ + ['FluentAI/status/1116417904831029248', + 'Amazon’s Alexa isn’t just AI — thousands of humans are listening', + 'One of the only ways to improve Alexa is to have human beings check it for errors', + 'theverge.com'], + ['Thom_Wolf/status/1122466524860702729', 'facebookresearch/fairseq', 'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.', diff --git a/tests/test_timeline.py b/tests/test_timeline.py index b56d6ad..919aa70 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -6,7 +6,7 @@ normal = [['jack'], ['elonmusk']] after = [['jack', '1681686036294803456'], ['elonmusk', '1681686036294803456']] -no_more = [['mobile_test_8?cursor=1000']] +no_more = [['mobile_test_8?cursor=DAABCgABF4YVAqN___kKAAICNn_4msIQAAgAAwAAAAIAAA']] empty = [['emptyuser'], ['mobile_test_10']] diff --git a/tests/test_tweet_media.py b/tests/test_tweet_media.py index 7a00983..f54cea7 100644 --- a/tests/test_tweet_media.py +++ b/tests/test_tweet_media.py @@ -14,7 +14,7 @@ poll = [ image = [ ['mobile_test/status/519364660823207936', 'BzUnaDFCUAAmrjs'], - ['mobile_test_2/status/324619691039543297', 'BIFH45vCUAAQecj'] + #['mobile_test_2/status/324619691039543297', 'BIFH45vCUAAQecj'] ] gif = [ From 03794a8d4a0eb13fccdf88d5f6633956daaf6564 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 25 Aug 2023 16:32:33 +0200 Subject: [PATCH 043/309] Cleanup --- src/parser.nim | 1 - 1 file changed, 1 deletion(-) diff --git a/src/parser.nim b/src/parser.nim index d5190a3..4087a79 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -401,7 +401,6 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if self: result.after = thread else: - echo "adding thread: ", thread.content.len result.replies.content.add thread elif entryId.startsWith("cursor-bottom"): result.replies.bottom = e{"content", "itemContent", "value"}.getStr From 7630f57f17246ffb60d4f5472d17af5fc3c6fa9f Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 26 Aug 2023 05:16:38 +0200 Subject: [PATCH 044/309] Fix cards not being displayed --- src/parser.nim | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 4087a79..914c038 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -4,7 +4,7 @@ import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard -proc parseGraphTweet(js: JsonNode): Tweet +proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet proc parseUser(js: JsonNode; id=""): User = if js.isNull: return @@ -306,7 +306,7 @@ proc parsePhotoRail*(js: JsonNode): PhotoRail = if url.len == 0: continue result.add GalleryPhoto(url: url, tweetId: $t.id) -proc parseGraphTweet(js: JsonNode): Tweet = +proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = if js.kind == JNull: return Tweet() @@ -322,9 +322,9 @@ proc parseGraphTweet(js: JsonNode): Tweet = of "TweetPreviewDisplay": return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") of "TweetWithVisibilityResults": - return parseGraphTweet(js{"tweet"}) + return parseGraphTweet(js{"tweet"}, isLegacy) - var jsCard = copy(js{"card", "legacy"}) + var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"}) if jsCard.kind != JNull: var values = newJObject() for val in jsCard["binding_values"]: @@ -339,7 +339,7 @@ proc parseGraphTweet(js: JsonNode): Tweet = result.expandNoteTweetEntities(noteTweet) if result.quote.isSome: - result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) + result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy)) proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = for t in js{"content", "items"}: @@ -355,14 +355,14 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = else: ("content", "tweetResult") with content, t{"item", contentKey}: - result.thread.content.add parseGraphTweet(content{resultKey, "result"}) + result.thread.content.add parseGraphTweet(content{resultKey, "result"}, isLegacy) if content{"tweetDisplayType"}.getStr == "SelfThread": result.self = true proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: - result = parseGraphTweet(tweet) + result = parseGraphTweet(tweet, false) proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) @@ -375,7 +375,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetResult) + let tweet = parseGraphTweet(tweetResult, true) if not tweet.available: tweet.id = parseBiggestInt(entryId.getId()) @@ -421,7 +421,7 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): with tweetResult, e{"content", "content", "tweetResult", "result"}: - let tweet = parseGraphTweet(tweetResult) + let tweet = parseGraphTweet(tweetResult, false) if not tweet.available: tweet.id = parseBiggestInt(entryId.getId()) result.tweets.content.add tweet @@ -432,7 +432,7 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = result.tweets.bottom = e{"content", "value"}.getStr if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry": with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}: - let tweet = parseGraphTweet(tweetResult) + let tweet = parseGraphTweet(tweetResult, false) tweet.pinned = true if not tweet.available and tweet.tombstone.len == 0: let entryId = i{"entry", "entryId"}.getEntryId @@ -454,7 +454,7 @@ proc parseGraphSearch*(js: JsonNode; after=""): Timeline = let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetRes) + let tweet = parseGraphTweet(tweetRes, true) if not tweet.available: tweet.id = parseBiggestInt(entryId.getId()) result.content.add tweet From 4ccf350dc74416173cad16eb0a7dd4b49239fd2c Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 29 Aug 2023 23:45:18 +0200 Subject: [PATCH 045/309] Improve .tokens output --- src/tokens.nim | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/tokens.nim b/src/tokens.nim index bb9696c..a3af9bf 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -1,5 +1,5 @@ -#i hate begging for this too em SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, times, json, random, strutils, tables +#SPDX-License-Identifier: AGPL-3.0-only +import asyncdispatch, times, json, random, strutils, tables, sets import types # max requests at a time per account to avoid race conditions @@ -19,14 +19,16 @@ proc getPoolJson*(): JsonNode = list = newJObject() totalReqs = 0 totalPending = 0 - totalLimited = 0 + limited: HashSet[string] reqsPerApi: Table[string, int] let now = epochTime().int for account in accountPool: totalPending.inc(account.pending) - list[account.id] = %*{ + + var includeAccount = false + let accountJson = %*{ "apis": newJObject(), "pending": account.pending, } @@ -36,17 +38,18 @@ proc getPoolJson*(): JsonNode = apiStatus = account.apis[api] obj = %*{} - if apiStatus.limited: - obj["limited"] = %true - inc totalLimited - if apiStatus.reset > now.int: obj["remaining"] = %apiStatus.remaining if "remaining" notin obj and not apiStatus.limited: continue - list[account.id]["apis"][$api] = obj + if apiStatus.limited: + obj["limited"] = %true + limited.incl account.id + + accountJson{"apis", $api} = obj + includeAccount = true let maxReqs = @@ -64,9 +67,12 @@ proc getPoolJson*(): JsonNode = reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs totalReqs.inc(reqs) + if includeAccount: + list[account.id] = accountJson + return %*{ "amount": accountPool.len, - "limited": totalLimited, + "limited": limited.card, "requests": totalReqs, "pending": totalPending, "apis": reqsPerApi, From 986b91ac733b36949bd9ac13c78409c4a98f83a0 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 29 Aug 2023 23:58:03 +0200 Subject: [PATCH 046/309] Handle ProtocolError and BadClientError equally --- src/http_pool.nim | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/http_pool.nim b/src/http_pool.nim index b4e3cee..664e9a6 100644 --- a/src/http_pool.nim +++ b/src/http_pool.nim @@ -39,11 +39,8 @@ template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped = try: body - except ProtocolError: - # Twitter closed the connection, retry - body - except BadClientError: - # Twitter returned 503, we need a new client + except BadClientError, ProtocolError: + # Twitter returned 503 or closed the connection, we need a new client pool.release(c, true) badClient = false c = pool.acquire(heads) From 898b19b92f3121b2185ba3a59381be2ea8299f44 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 30 Aug 2023 03:04:22 +0200 Subject: [PATCH 047/309] Improve rate limit handling, minor refactor --- src/apiutils.nim | 67 ++++++++++++++++++++---------------------------- src/tokens.nim | 28 ++++++++++++-------- src/types.nim | 2 +- 3 files changed, 47 insertions(+), 50 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 453b36a..37afded 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -61,13 +61,6 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = "DNT": "1" }) -template updateAccount() = - if resp.headers.hasKey(rlRemaining): - let - remaining = parseInt(resp.headers[rlRemaining]) - reset = parseInt(resp.headers[rlReset]) - account.setRateLimit(api, remaining, reset) - template fetchImpl(result, fetchBody) {.dirty.} = once: pool = HttpPool() @@ -89,28 +82,46 @@ template fetchImpl(result, fetchBody) {.dirty.} = badClient = true raise newException(BadClientError, "Bad client") + if resp.headers.hasKey(rlRemaining): + let + remaining = parseInt(resp.headers[rlRemaining]) + reset = parseInt(resp.headers[rlReset]) + account.setRateLimit(api, remaining, reset) + if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": result = uncompress(result, dfGzip) - else: - echo "non-gzip body, url: ", url, ", body: ", result + + if result.startsWith("{\"errors"): + let errors = result.fromJson(Errors) + if errors in {expiredToken, badToken}: + echo "fetch error: ", errors + invalidate(account) + raise rateLimitError() + elif errors in {rateLimited}: + # rate limit hit, resets after 24 hours + setLimited(account, api) + raise rateLimitError() + elif result.startsWith("429 Too Many Requests"): + account.apis[api].remaining = 0 + # rate limit hit, resets after the 15 minute window + raise rateLimitError() fetchBody - release(account, used=true) - if resp.status == $Http400: raise newException(InternalError, $url) except InternalError as e: raise e except BadClientError as e: - release(account, used=true) + raise e + except OSError as e: raise e except Exception as e: echo "error: ", e.name, ", msg: ", e.msg, ", accountId: ", account.id, ", url: ", url - if "length" notin e.msg and "descriptor" notin e.msg: - release(account, invalid=true) raise rateLimitError() + finally: + release(account) proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = var body: string @@ -121,36 +132,14 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = echo resp.status, ": ", body, " --- url: ", url result = newJNull() - updateAccount() - let error = result.getError - if error in {invalidToken, badToken}: - echo "fetch error: ", result.getError - release(account, invalid=true) + if error in {expiredToken, badToken}: + echo "fetchBody error: ", error + invalidate(account) raise rateLimitError() - if body.startsWith("{\"errors"): - let errors = body.fromJson(Errors) - if errors in {invalidToken, badToken}: - echo "fetch error: ", errors - release(account, invalid=true) - raise rateLimitError() - elif errors in {rateLimited}: - account.apis[api].limited = true - account.apis[api].limitedAt = epochTime().int - echo "[accounts] rate limited, api: ", api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id - proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = fetchImpl result: if not (result.startsWith('{') or result.startsWith('[')): echo resp.status, ": ", result, " --- url: ", url result.setLen(0) - - updateAccount() - - if result.startsWith("{\"errors"): - let errors = result.fromJson(Errors) - if errors in {invalidToken, badToken}: - echo "fetch error: ", errors - release(account, invalid=true) - raise rateLimitError() diff --git a/src/tokens.nim b/src/tokens.nim index a3af9bf..c620bc7 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -11,7 +11,7 @@ var accountPool: seq[GuestAccount] enableLogging = false -template log(str) = +template log(str: varargs[string, `$`]) = if enableLogging: echo "[accounts] ", str proc getPoolJson*(): JsonNode = @@ -91,7 +91,7 @@ proc isLimited(account: GuestAccount; api: Api): bool = if limit.limited and (epochTime().int - limit.limitedAt) > dayInSeconds: account.apis[api].limited = false - log "resetting limit, api: " & $api & ", id: " & $account.id + log "resetting limit, api: ", api, ", id: ", account.id return limit.limited or (limit.remaining <= 10 and limit.reset > epochTime().int) else: @@ -100,15 +100,18 @@ proc isLimited(account: GuestAccount; api: Api): bool = proc isReady(account: GuestAccount; api: Api): bool = not (account.isNil or account.pending > maxConcurrentReqs or account.isLimited(api)) -proc release*(account: GuestAccount; used=false; invalid=false) = +proc invalidate*(account: var GuestAccount) = if account.isNil: return - if invalid: - log "discarding invalid account: " & account.id + log "invalidating expired account: ", account.id - let idx = accountPool.find(account) - if idx > -1: accountPool.delete(idx) - elif used: - dec account.pending + # TODO: This isn't sufficient, but it works for now + let idx = accountPool.find(account) + if idx > -1: accountPool.delete(idx) + account = nil + +proc release*(account: GuestAccount; invalid=false) = + if account.isNil: return + dec account.pending proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} = for i in 0 ..< accountPool.len: @@ -119,9 +122,14 @@ proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} = if not result.isNil and result.isReady(api): inc result.pending else: - log "no accounts available for API: " & $api + log "no accounts available for API: ", api raise rateLimitError() +proc setLimited*(account: GuestAccount; api: Api) = + account.apis[api].limited = true + account.apis[api].limitedAt = epochTime().int + log "rate limited, api: ", api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id + proc setRateLimit*(account: GuestAccount; api: Api; remaining, reset: int) = # avoid undefined behavior in race conditions if api in account.apis: diff --git a/src/types.nim b/src/types.nim index fcb24c0..8a7a66e 100644 --- a/src/types.nim +++ b/src/types.nim @@ -56,7 +56,7 @@ type userNotFound = 50 suspended = 63 rateLimited = 88 - invalidToken = 89 + expiredToken = 89 listIdOrSlug = 112 tweetNotFound = 144 tweetNotAuthorized = 179 From 37b58a5a7e52b897c565677016c8d7bf56494bc4 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 30 Aug 2023 03:43:49 +0200 Subject: [PATCH 048/309] Fix accounts logging --- src/tokens.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tokens.nim b/src/tokens.nim index c620bc7..3628ef6 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -12,7 +12,7 @@ var enableLogging = false template log(str: varargs[string, `$`]) = - if enableLogging: echo "[accounts] ", str + if enableLogging: echo "[accounts] ", str.join("") proc getPoolJson*(): JsonNode = var From 282ce8b0e9088a0a215116ce234c0b46f1e0d55f Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 31 Aug 2023 01:29:54 +0200 Subject: [PATCH 049/309] Add 429 logging --- src/apiutils.nim | 1 + src/tokens.nim | 2 +- src/types.nim | 2 -- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 37afded..e5b9be2 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -103,6 +103,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = setLimited(account, api) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): + echo "[accounts] 429 error, API: ", api, ", account: ", account.id account.apis[api].remaining = 0 # rate limit hit, resets after the 15 minute window raise rateLimitError() diff --git a/src/tokens.nim b/src/tokens.nim index 3628ef6..a4ebe7f 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -109,7 +109,7 @@ proc invalidate*(account: var GuestAccount) = if idx > -1: accountPool.delete(idx) account = nil -proc release*(account: GuestAccount; invalid=false) = +proc release*(account: GuestAccount) = if account.isNil: return dec account.pending diff --git a/src/types.nim b/src/types.nim index 8a7a66e..4cacc4b 100644 --- a/src/types.nim +++ b/src/types.nim @@ -40,8 +40,6 @@ type id*: string oauthToken*: string oauthSecret*: string - # init*: Time - lastUse*: Time pending*: int apis*: Table[Api, RateLimit] From 82beb5da8c60a981f0ce61fc99c8f23dff3b3865 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 31 Aug 2023 01:31:27 +0200 Subject: [PATCH 050/309] Add empty oauth token logging --- src/apiutils.nim | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apiutils.nim b/src/apiutils.nim index e5b9be2..0b1db26 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -67,6 +67,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = var account = await getGuestAccount(api) if account.oauthToken.len == 0: + echo "[accounts] Empty oauth token, account: ", account.id raise rateLimitError() try: From 84dcf4907907fc59a60e7bf2a7db738789e3e868 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 31 Aug 2023 05:06:47 +0200 Subject: [PATCH 051/309] Fix negative pending requests bug --- src/tokens.nim | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tokens.nim b/src/tokens.nim index a4ebe7f..3e20597 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -116,7 +116,6 @@ proc release*(account: GuestAccount) = proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} = for i in 0 ..< accountPool.len: if result.isReady(api): break - release(result) result = accountPool.sample() if not result.isNil and result.isReady(api): From b8fe212e941ebb27a39b0900cfe271d700738f22 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 1 Sep 2023 21:37:34 +0200 Subject: [PATCH 052/309] Add media proxying error logging --- src/routes/media.nim | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/media.nim b/src/routes/media.nim index e63a0f8..d335c97 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -37,6 +37,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = try: let res = await client.get(url) if res.status != "200 OK": + echo "[media] Proxying media failed, status: $1, url: $2, body: $3" % [res.status, url, await res.body] return Http404 let hashed = $hash(url) @@ -65,6 +66,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = await request.client.send(data) data.setLen 0 except HttpRequestError, ProtocolError, OSError: + echo "[media] Proxying media exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] result = Http404 finally: client.close() From 4250245263b77bd2b0ecd9a31c0e4000d8e2e6f7 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 2 Sep 2023 07:28:56 +0200 Subject: [PATCH 053/309] Shorten media proxy error log --- src/routes/media.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/media.nim b/src/routes/media.nim index d335c97..eacd1f8 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -37,7 +37,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = try: let res = await client.get(url) if res.status != "200 OK": - echo "[media] Proxying media failed, status: $1, url: $2, body: $3" % [res.status, url, await res.body] + echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] return Http404 let hashed = $hash(url) @@ -66,7 +66,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = await request.client.send(data) data.setLen 0 except HttpRequestError, ProtocolError, OSError: - echo "[media] Proxying media exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] + echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] result = Http404 finally: client.close() From fcd74e8048362fcf8284871ee067099e8de28a89 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 2 Sep 2023 08:15:58 +0200 Subject: [PATCH 054/309] Retry rate limited requests with different account --- src/apiutils.nim | 46 ++++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 0b1db26..9ac101e 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -120,28 +120,38 @@ template fetchImpl(result, fetchBody) {.dirty.} = except OSError as e: raise e except Exception as e: - echo "error: ", e.name, ", msg: ", e.msg, ", accountId: ", account.id, ", url: ", url + let id = if account.isNil: "null" else: account.id + echo "error: ", e.name, ", msg: ", e.msg, ", accountId: ", id, ", url: ", url raise rateLimitError() finally: release(account) -proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = - var body: string - fetchImpl body: - if body.startsWith('{') or body.startsWith('['): - result = parseJson(body) - else: - echo resp.status, ": ", body, " --- url: ", url - result = newJNull() +template retry(bod) = + try: + bod + except RateLimitError: + echo "[accounts] Rate limited, retrying ", api, " request..." + bod - let error = result.getError - if error in {expiredToken, badToken}: - echo "fetchBody error: ", error - invalidate(account) - raise rateLimitError() +proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = + retry: + var body: string + fetchImpl body: + if body.startsWith('{') or body.startsWith('['): + result = parseJson(body) + else: + echo resp.status, ": ", body, " --- url: ", url + result = newJNull() + + let error = result.getError + if error in {expiredToken, badToken}: + echo "fetchBody error: ", error + invalidate(account) + raise rateLimitError() proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = - fetchImpl result: - if not (result.startsWith('{') or result.startsWith('[')): - echo resp.status, ": ", result, " --- url: ", url - result.setLen(0) + retry: + fetchImpl result: + if not (result.startsWith('{') or result.startsWith('[')): + echo resp.status, ": ", result, " --- url: ", url + result.setLen(0) From 14f9a092d832c0aaf7eb4900ab36a76c6653c364 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 14 Sep 2023 23:35:41 +0000 Subject: [PATCH 055/309] Fix crash on missing quote tweet data crash --- src/parser.nim | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/parser.nim b/src/parser.nim index 914c038..776f176 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -324,6 +324,9 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = of "TweetWithVisibilityResults": return parseGraphTweet(js{"tweet"}, isLegacy) + if not js.hasKey("legacy"): + return Tweet() + var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"}) if jsCard.kind != JNull: var values = newJObject() From 7abcb489f4176532322669e97eb2503a78b2bbc2 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 18 Sep 2023 17:15:09 +0000 Subject: [PATCH 056/309] Increase photo rail cache ttl --- src/redis_cache.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 2387a42..a8b5ff8 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -85,7 +85,7 @@ proc cache*(data: List) {.async.} = await setEx(data.listKey, listCacheTime, compress(toFlatty(data))) proc cache*(data: PhotoRail; name: string) {.async.} = - await setEx("pr:" & toLower(name), baseCacheTime, compress(toFlatty(data))) + await setEx("pr:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data))) proc cache*(data: User) {.async.} = if data.username.len == 0: return From 7d147899103bf1a21b291641fd4230a02ba48fab Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 18 Sep 2023 18:24:23 +0000 Subject: [PATCH 057/309] Improve guest accounts loading, add JSONL support --- .gitignore | 1 + src/experimental/parser/guestaccount.nim | 20 ++++++++++++++++++++ src/experimental/types/guestaccount.nim | 4 ++++ src/nitter.nim | 4 +--- src/tokens.nim | 23 +++++++++++++++-------- 5 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 src/experimental/parser/guestaccount.nim create mode 100644 src/experimental/types/guestaccount.nim diff --git a/.gitignore b/.gitignore index d43cc3f..ea520dc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ nitter /public/css/style.css /public/md/*.html nitter.conf +guest_accounts.json* dump.rdb diff --git a/src/experimental/parser/guestaccount.nim b/src/experimental/parser/guestaccount.nim new file mode 100644 index 0000000..4d8ff47 --- /dev/null +++ b/src/experimental/parser/guestaccount.nim @@ -0,0 +1,20 @@ +import jsony +import ../types/guestaccount +from ../../types import GuestAccount + +proc toGuestAccount(account: RawAccount): GuestAccount = + let id = account.oauthToken[0 ..< account.oauthToken.find('-')] + result = GuestAccount( + id: id, + oauthToken: account.oauthToken, + oauthSecret: account.oauthTokenSecret + ) + +proc parseGuestAccount*(raw: string): GuestAccount = + let rawAccount = raw.fromJson(RawAccount) + result = rawAccount.toGuestAccount + +proc parseGuestAccounts*(path: string): seq[GuestAccount] = + let rawAccounts = readFile(path).fromJson(seq[RawAccount]) + for account in rawAccounts: + result.add account.toGuestAccount diff --git a/src/experimental/types/guestaccount.nim b/src/experimental/types/guestaccount.nim new file mode 100644 index 0000000..244edb3 --- /dev/null +++ b/src/experimental/types/guestaccount.nim @@ -0,0 +1,4 @@ +type + RawAccount* = object + oauthToken*: string + oauthTokenSecret*: string diff --git a/src/nitter.nim b/src/nitter.nim index 4a4ec13..1b4862b 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -3,7 +3,6 @@ import asyncdispatch, strformat, logging from net import Port from htmlgen import a from os import getEnv -from json import parseJson import jester @@ -21,9 +20,8 @@ let (cfg, fullCfg) = getConfig(configPath) accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json") - accounts = parseJson(readFile(accountsPath)) -initAccountPool(cfg, parseJson(readFile(accountsPath))) +initAccountPool(cfg, accountsPath) if not cfg.enableDebug: # Silence Jester's query warning diff --git a/src/tokens.nim b/src/tokens.nim index 3e20597..b8a50e3 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -1,6 +1,7 @@ #SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, times, json, random, strutils, tables, sets +import asyncdispatch, times, json, random, strutils, tables, sets, os import types +import experimental/parser/guestaccount # max requests at a time per account to avoid race conditions const @@ -141,12 +142,18 @@ proc setRateLimit*(account: GuestAccount; api: Api; remaining, reset: int) = account.apis[api] = RateLimit(remaining: remaining, reset: reset) -proc initAccountPool*(cfg: Config; accounts: JsonNode) = +proc initAccountPool*(cfg: Config; path: string) = enableLogging = cfg.enableDebug - for account in accounts: - accountPool.add GuestAccount( - id: account{"user", "id_str"}.getStr, - oauthToken: account{"oauth_token"}.getStr, - oauthSecret: account{"oauth_token_secret"}.getStr, - ) + let jsonlPath = if path.endsWith(".json"): (path & 'l') else: path + + if fileExists(jsonlPath): + log "Parsing JSONL guest accounts file: ", jsonlPath + for line in jsonlPath.lines: + accountPool.add parseGuestAccount(line) + elif fileExists(path): + log "Parsing JSON guest accounts file: ", path + accountPool = parseGuestAccounts(path) + else: + echo "[accounts] ERROR: ", path, " not found. This file is required to authenticate API requests." + quit 1 From 537af7fd5e846e614315fbbff8741d3c7f0c74ba Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 19 Sep 2023 01:29:41 +0000 Subject: [PATCH 058/309] Improve Liberapay css for Firefox compatibility --- src/sass/navbar.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index cf9c80e..47a8765 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -70,8 +70,9 @@ nav { .lp { height: 14px; - margin-top: 2px; - display: block; + display: inline-block; + position: relative; + top: 2px; fill: var(--fg_nav); &:hover { From 735b30c2da336cc57be2b98c48a6f7e826fdaed0 Mon Sep 17 00:00:00 2001 From: LS <66217791+DrSocket@users.noreply.github.com> Date: Mon, 30 Oct 2023 13:13:06 +0100 Subject: [PATCH 059/309] fix(nitter): add graphql user search (#1047) * fix(nitter): add graphql user search * fix(nitter): rm gitignore 2nd guest_accounts * fix(nitter): keep query from user search in result. remove personal mods * fix(nitter): removce useless line gitignore --- src/api.nim | 28 ++++++++++++++++------------ src/consts.nim | 3 +-- src/parser.nim | 24 +++++++++++++++--------- src/routes/search.nim | 2 +- src/tokens.nim | 1 - src/types.nim | 1 - 6 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/api.nim b/src/api.nim index 4ac999c..d6a4564 100644 --- a/src/api.nim +++ b/src/api.nim @@ -112,25 +112,29 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = if after.len > 0: variables["cursor"] = % after let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} - result = parseGraphSearch(await fetch(url, Api.search), after) + result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) result.query = query -proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = +proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = if query.text.len == 0: return Result[User](query: query, beginning: true) - let - page = if page.len == 0: "1" else: page - url = userSearch ? genParams({"q": query.text, "skip_status": "1", "page": page}) - js = await fetchRaw(url, Api.userSearch) - - result = parseUsers(js) + var + variables = %*{ + "rawQuery": query.text, + "count": 20, + "product": "People", + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false + } + if after.len > 0: + variables["cursor"] = % after + result.beginning = false + let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} + result = parseGraphSearch[User](await fetch(url, Api.search), after) result.query = query - if page.len == 0: - result.bottom = "2" - elif page.allCharsInSet(Digits): - result.bottom = $(parseInt(page) + 1) proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return diff --git a/src/consts.nim b/src/consts.nim index 96cea47..d3a3d80 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -9,7 +9,6 @@ const activate* = $(api / "1.1/guest/activate.json") photoRail* = api / "1.1/statuses/media_timeline.json" - userSearch* = api / "1.1/users/search.json" graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" @@ -35,7 +34,7 @@ const "include_user_entities": "1", "include_ext_reply_count": "1", "include_ext_is_blue_verified": "1", - #"include_ext_verified_type": "1", + # "include_ext_verified_type": "1", "include_ext_media_color": "0", "cards_platform": "Web-13", "tweet_mode": "extended", diff --git a/src/parser.nim b/src/parser.nim index 776f176..cebf6f1 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -443,8 +443,8 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = tweet.id = parseBiggestInt(entryId) result.pinned = some tweet -proc parseGraphSearch*(js: JsonNode; after=""): Timeline = - result = Timeline(beginning: after.len == 0) +proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = + result = Result[T](beginning: after.len == 0) let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} if instructions.len == 0: @@ -455,13 +455,19 @@ proc parseGraphSearch*(js: JsonNode; after=""): Timeline = if typ == "TimelineAddEntries": for e in instruction{"entries"}: let entryId = e{"entryId"}.getStr - if entryId.startsWith("tweet"): - with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetRes, true) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) - result.content.add tweet - elif entryId.startsWith("cursor-bottom"): + when T is Tweets: + if entryId.startsWith("tweet"): + with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetRes) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.content.add tweet + elif T is User: + if entryId.startsWith("user"): + with userRes, e{"content", "itemContent"}: + result.content.add parseGraphUser(userRes) + + if entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): diff --git a/src/routes/search.nim b/src/routes/search.nim index 676229e..e9f991d 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -29,7 +29,7 @@ proc createSearchRouter*(cfg: Config) = redirect("/" & q) var users: Result[User] try: - users = await getUserSearch(query, getCursor()) + users = await getGraphUserSearch(query, getCursor()) except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) diff --git a/src/tokens.nim b/src/tokens.nim index b8a50e3..ca74ddc 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -62,7 +62,6 @@ proc getPoolJson*(): JsonNode = Api.userRestId, Api.userScreenName, Api.tweetResult, Api.list, Api.listTweets, Api.listMembers, Api.listBySlug: 500 - of Api.userSearch: 900 reqs = maxReqs - apiStatus.remaining reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs diff --git a/src/types.nim b/src/types.nim index 4cacc4b..3f5f8ac 100644 --- a/src/types.nim +++ b/src/types.nim @@ -19,7 +19,6 @@ type tweetResult photoRail search - userSearch list listBySlug listMembers From 32e3469e3a580464a79c6b2b6bfbaa6757bd8cfe Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 31 Oct 2023 05:53:55 +0000 Subject: [PATCH 060/309] Fix multi-user timelines --- src/query.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/query.nim b/src/query.nim index d128f6f..06e1da2 100644 --- a/src/query.nim +++ b/src/query.nim @@ -60,7 +60,7 @@ proc genQueryParam*(query: Query): string = param &= "OR " if query.fromUser.len > 0 and query.kind in {posts, media}: - param &= "filter:self_threads OR-filter:replies " + param &= "filter:self_threads OR -filter:replies " if "nativeretweets" notin query.excludes: param &= "include:nativeretweets " From edad09f4c934fb44da008256994ba40347b0f9c9 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 31 Oct 2023 08:31:51 +0000 Subject: [PATCH 061/309] Update nimcrypto and jsony --- nitter.nimble | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nitter.nimble b/nitter.nimble index e6a1909..20aab81 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -14,7 +14,7 @@ requires "nim >= 1.4.8" requires "jester#baca3f" requires "karax#5cf360c" requires "sass#7dfdd03" -requires "nimcrypto#4014ef9" +requires "nimcrypto#a079df9" requires "markdown#158efe3" requires "packedjson#9e6fbb6" requires "supersnappy#6c94198" @@ -22,7 +22,7 @@ requires "redpool#8b7c1db" requires "https://github.com/zedeus/redis#d0a0e6f" requires "zippy#ca5989a" requires "flatty#e668085" -requires "jsony#ea811be" +requires "jsony#1de1f08" requires "oauth#b8c163b" # Tasks From 089275826cae70c30183e1bd21b49538e43bd4d9 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 31 Oct 2023 11:32:21 +0000 Subject: [PATCH 062/309] Bump minimum Nim version --- config.nims | 7 +------ nitter.nimble | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/config.nims b/config.nims index b7e52d0..4a7af27 100644 --- a/config.nims +++ b/config.nims @@ -7,12 +7,7 @@ # disable annoying warnings warning("GcUnsafe2", off) +warning("HoleEnumConv", off) hint("XDeclaredButNotUsed", off) hint("XCannotRaiseY", off) hint("User", off) - -const - nimVersion = (major: NimMajor, minor: NimMinor, patch: NimPatch) - -when nimVersion >= (1, 6, 0): - warning("HoleEnumConv", off) diff --git a/nitter.nimble b/nitter.nimble index 20aab81..37f9229 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -10,7 +10,7 @@ bin = @["nitter"] # Dependencies -requires "nim >= 1.4.8" +requires "nim >= 1.6.10" requires "jester#baca3f" requires "karax#5cf360c" requires "sass#7dfdd03" From 412055864940a79d3203aa41568c6ed67c5bb5f8 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 31 Oct 2023 12:04:32 +0000 Subject: [PATCH 063/309] Replace /.tokens with /.health and /.accounts --- nitter.example.conf | 2 +- src/apiutils.nim | 4 +- src/{tokens.nim => auth.nim} | 113 +++++++++++++++-------- src/experimental/parser/guestaccount.nim | 3 +- src/nitter.nim | 2 +- src/routes/debug.nim | 9 +- src/types.nim | 2 +- 7 files changed, 86 insertions(+), 49 deletions(-) rename src/{tokens.nim => auth.nim} (69%) diff --git a/nitter.example.conf b/nitter.example.conf index 0d4deb7..f0b4214 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -23,7 +23,7 @@ redisMaxConnections = 30 hmacKey = "secretkey" # random key for cryptographic signing of video urls base64Media = false # use base64 encoding for proxied media urls enableRSS = true # set this to false to disable RSS feeds -enableDebug = false # enable request logs and debug endpoints (/.tokens) +enableDebug = false # enable request logs and debug endpoints (/.accounts) proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" tokenCount = 10 diff --git a/src/apiutils.nim b/src/apiutils.nim index 9ac101e..1ff05eb 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import jsony, packedjson, zippy, oauth1 -import types, tokens, consts, parserutils, http_pool +import types, auth, consts, parserutils, http_pool import experimental/types/common const @@ -120,7 +120,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = except OSError as e: raise e except Exception as e: - let id = if account.isNil: "null" else: account.id + let id = if account.isNil: "null" else: $account.id echo "error: ", e.name, ", msg: ", e.msg, ", accountId: ", id, ", url: ", url raise rateLimitError() finally: diff --git a/src/tokens.nim b/src/auth.nim similarity index 69% rename from src/tokens.nim rename to src/auth.nim index ca74ddc..560fb84 100644 --- a/src/tokens.nim +++ b/src/auth.nim @@ -1,5 +1,5 @@ #SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, times, json, random, strutils, tables, sets, os +import asyncdispatch, times, json, random, strutils, tables, intsets, os import types import experimental/parser/guestaccount @@ -7,6 +7,21 @@ import experimental/parser/guestaccount const maxConcurrentReqs = 2 dayInSeconds = 24 * 60 * 60 + apiMaxReqs: Table[Api, int] = { + Api.search: 50, + Api.tweetDetail: 150, + Api.photoRail: 180, + Api.userTweets: 500, + Api.userTweetsAndReplies: 500, + Api.userMedia: 500, + Api.userRestId: 500, + Api.userScreenName: 500, + Api.tweetResult: 500, + Api.list: 500, + Api.listTweets: 500, + Api.listMembers: 500, + Api.listBySlug: 500 + }.toTable var accountPool: seq[GuestAccount] @@ -15,20 +30,64 @@ var template log(str: varargs[string, `$`]) = if enableLogging: echo "[accounts] ", str.join("") -proc getPoolJson*(): JsonNode = - var - list = newJObject() - totalReqs = 0 - totalPending = 0 - limited: HashSet[string] - reqsPerApi: Table[string, int] - +proc getAccountPoolHealth*(): JsonNode = let now = epochTime().int - for account in accountPool: - totalPending.inc(account.pending) + var + totalReqs = 0 + limited: IntSet + reqsPerApi: Table[string, int] + oldest = now + newest = 0 + average = 0 - var includeAccount = false + for account in accountPool: + # Twitter snowflake conversion + let created = ((account.id shr 22) + 1288834974657) div 1000 + + if created > newest: + newest = created + if created < oldest: + oldest = created + average.inc created + + for api in account.apis.keys: + let + apiStatus = account.apis[api] + reqs = apiMaxReqs[api] - apiStatus.remaining + + reqsPerApi.mgetOrPut($api, 0).inc reqs + totalReqs.inc reqs + + if apiStatus.limited: + limited.incl account.id + + if accountPool.len > 0: + average = average div accountPool.len + else: + oldest = 0 + average = 0 + + return %*{ + "accounts": %*{ + "total": accountPool.len, + "active": accountPool.len - limited.card, + "limited": limited.card, + "oldest": $fromUnix(oldest), + "newest": $fromUnix(newest), + "average": $fromUnix(average) + }, + "requests": %*{ + "total": totalReqs, + "apis": reqsPerApi + } + } + +proc getAccountPoolDebug*(): JsonNode = + let now = epochTime().int + var list = newJObject() + + for account in accountPool: let accountJson = %*{ "apis": newJObject(), "pending": account.pending, @@ -47,37 +106,11 @@ proc getPoolJson*(): JsonNode = if apiStatus.limited: obj["limited"] = %true - limited.incl account.id accountJson{"apis", $api} = obj - includeAccount = true + list[$account.id] = accountJson - let - maxReqs = - case api - of Api.search: 50 - of Api.tweetDetail: 150 - of Api.photoRail: 180 - of Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, - Api.userRestId, Api.userScreenName, - Api.tweetResult, - Api.list, Api.listTweets, Api.listMembers, Api.listBySlug: 500 - reqs = maxReqs - apiStatus.remaining - - reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs - totalReqs.inc(reqs) - - if includeAccount: - list[account.id] = accountJson - - return %*{ - "amount": accountPool.len, - "limited": limited.card, - "requests": totalReqs, - "pending": totalPending, - "apis": reqsPerApi, - "accounts": list - } + return %list proc rateLimitError*(): ref RateLimitError = newException(RateLimitError, "rate limited") diff --git a/src/experimental/parser/guestaccount.nim b/src/experimental/parser/guestaccount.nim index 4d8ff47..f7e6d34 100644 --- a/src/experimental/parser/guestaccount.nim +++ b/src/experimental/parser/guestaccount.nim @@ -1,3 +1,4 @@ +import std/strutils import jsony import ../types/guestaccount from ../../types import GuestAccount @@ -5,7 +6,7 @@ from ../../types import GuestAccount proc toGuestAccount(account: RawAccount): GuestAccount = let id = account.oauthToken[0 ..< account.oauthToken.find('-')] result = GuestAccount( - id: id, + id: parseBiggestInt(id), oauthToken: account.oauthToken, oauthSecret: account.oauthTokenSecret ) diff --git a/src/nitter.nim b/src/nitter.nim index 1b4862b..dfc1dfd 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -6,7 +6,7 @@ from os import getEnv import jester -import types, config, prefs, formatters, redis_cache, http_pool, tokens +import types, config, prefs, formatters, redis_cache, http_pool, auth import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, debug, diff --git a/src/routes/debug.nim b/src/routes/debug.nim index 192786e..895a285 100644 --- a/src/routes/debug.nim +++ b/src/routes/debug.nim @@ -1,10 +1,13 @@ # SPDX-License-Identifier: AGPL-3.0-only import jester import router_utils -import ".."/[tokens, types] +import ".."/[auth, types] proc createDebugRouter*(cfg: Config) = router debug: - get "/.tokens": + get "/.health": + respJson getAccountPoolHealth() + + get "/.accounts": cond cfg.enableDebug - respJson getPoolJson() + respJson getAccountPoolDebug() diff --git a/src/types.nim b/src/types.nim index 3f5f8ac..3b0d55c 100644 --- a/src/types.nim +++ b/src/types.nim @@ -36,7 +36,7 @@ type limitedAt*: int GuestAccount* = ref object - id*: string + id*: BiggestInt oauthToken*: string oauthSecret*: string pending*: int From b62d73dbd373f08af07c7a79efcd790d3bc1a49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89milien=20=28perso=29?= <4016501+unixfox@users.noreply.github.com> Date: Tue, 31 Oct 2023 23:33:08 +0100 Subject: [PATCH 064/309] nim version min require + update dockerfile arm (#1053) --- Dockerfile.arm64 | 6 +++--- nitter.nimble | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 6cd6744..fbad812 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -1,7 +1,7 @@ -FROM alpine:3.17 as nim +FROM alpine:3.18 as nim LABEL maintainer="setenforce@protonmail.com" -RUN apk --no-cache add gcc git libc-dev libsass-dev "nim=1.6.8-r0" nimble pcre +RUN apk --no-cache add gcc git libc-dev libsass-dev "nim=1.6.14-r0" nimble pcre WORKDIR /src/nitter @@ -13,7 +13,7 @@ RUN nimble build -d:danger -d:lto -d:strip \ && nimble scss \ && nimble md -FROM alpine:3.17 +FROM alpine:3.18 WORKDIR /src/ RUN apk --no-cache add ca-certificates pcre openssl1.1-compat COPY --from=nim /src/nitter/nitter ./ diff --git a/nitter.nimble b/nitter.nimble index 7771b31..3a490a5 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -10,7 +10,7 @@ bin = @["nitter"] # Dependencies -requires "nim >= 1.4.8" +requires "nim >= 1.6.10" requires "jester#baca3f" requires "karax#5cf360c" requires "sass#7dfdd03" From b8103cf5010ea515c3b9722dbda19f374c6ebdaa Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 31 Oct 2023 23:02:45 +0000 Subject: [PATCH 065/309] Fix compilation on Nim 1.6.x --- src/auth.nim | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index 560fb84..97ee301 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -1,5 +1,5 @@ #SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, times, json, random, strutils, tables, intsets, os +import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os] import types import experimental/parser/guestaccount @@ -35,21 +35,21 @@ proc getAccountPoolHealth*(): JsonNode = var totalReqs = 0 - limited: IntSet + limited: PackedSet[BiggestInt] reqsPerApi: Table[string, int] - oldest = now - newest = 0 - average = 0 + oldest = now.int64 + newest = 0'i64 + average = 0'i64 for account in accountPool: # Twitter snowflake conversion - let created = ((account.id shr 22) + 1288834974657) div 1000 + let created = int64(((account.id shr 22) + 1288834974657) div 1000) if created > newest: newest = created if created < oldest: oldest = created - average.inc created + average += created for api in account.apis.keys: let From 60a82563da979f81c24eb51b7ae031f4086c03fd Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 31 Oct 2023 23:46:24 +0000 Subject: [PATCH 066/309] Run tests on multiple Nim versions --- .github/workflows/run-tests.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 37979cb..0af20a3 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,6 +11,12 @@ on: jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + nim: + - "1.6.10" + - "1.6.x" + - "2.0.x" steps: - uses: actions/checkout@v3 with: @@ -28,7 +34,8 @@ jobs: cache: "pip" - uses: jiro4989/setup-nim-action@v1 with: - nim-version: "1.x" + nim-version: ${{ matrix.nim }} + repo-token: ${{ secrets.GITHUB_TOKEN }} - run: nimble build -d:release -Y - run: pip install seleniumbase - run: seleniumbase install chromedriver From b930a3d5bf4b0ce679b5086ae712b65f279f1a49 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 31 Oct 2023 23:54:11 +0000 Subject: [PATCH 067/309] Fix guest accounts CI setup --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 0af20a3..3fbe64a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -50,6 +50,6 @@ jobs: env: GUEST_ACCOUNTS: ${{ secrets.GUEST_ACCOUNTS }} run: | - echo $GUEST_ACCOUNTS > ./guest_accounts.json + echo $GUEST_ACCOUNTS > ./guest_accounts.jsonl ./nitter & pytest -n4 tests From 33bad37128abd3987e823d32e2ef90ea91f8e4f5 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 01:24:51 +0000 Subject: [PATCH 068/309] Fix guest accounts CI setup attempt 2 --- .github/workflows/run-tests.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3fbe64a..12adb1f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -44,12 +44,11 @@ jobs: run: | sudo apt install libsass-dev -y cp nitter.example.conf nitter.conf + sed -i 's/enableDebug = false/enableDebug = true/g' nimble md nimble scss + echo "${{ env.GUEST_ACCOUNTS }}" > ./guest_accounts.jsonl - name: Run tests - env: - GUEST_ACCOUNTS: ${{ secrets.GUEST_ACCOUNTS }} run: | - echo $GUEST_ACCOUNTS > ./guest_accounts.jsonl ./nitter & pytest -n4 tests From 006b91c90391e972b8e1377df18f7f1da5718c87 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 04:04:45 +0000 Subject: [PATCH 069/309] Prevent annoying warnings on devel --- src/parser.nim | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/parser.nim b/src/parser.nim index cebf6f1..fcee13f 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -323,6 +323,8 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") of "TweetWithVisibilityResults": return parseGraphTweet(js{"tweet"}, isLegacy) + else: + discard if not js.hasKey("legacy"): return Tweet() From b0b335106d992acdca2d82da82fe9dee89044404 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 04:06:42 +0000 Subject: [PATCH 070/309] Fix missing CI file argument --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 12adb1f..f9df235 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -44,7 +44,7 @@ jobs: run: | sudo apt install libsass-dev -y cp nitter.example.conf nitter.conf - sed -i 's/enableDebug = false/enableDebug = true/g' + sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf nimble md nimble scss echo "${{ env.GUEST_ACCOUNTS }}" > ./guest_accounts.jsonl From 58e73a14c576b275dde9f9af8d4f31f0f6202255 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 04:13:22 +0000 Subject: [PATCH 071/309] Fix guest accounts CI setup attempt 3 --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f9df235..948d284 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -47,7 +47,7 @@ jobs: sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf nimble md nimble scss - echo "${{ env.GUEST_ACCOUNTS }}" > ./guest_accounts.jsonl + echo "${{ vars.GUEST_ACCOUNTS }}" > ./guest_accounts.jsonl - name: Run tests run: | ./nitter & From 1d20bd01cb9db816e47b1911f9836c442f2726c9 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 04:16:26 +0000 Subject: [PATCH 072/309] Remove redundant "active" field from /.health --- src/auth.nim | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth.nim b/src/auth.nim index 97ee301..8be435c 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -71,7 +71,6 @@ proc getAccountPoolHealth*(): JsonNode = return %*{ "accounts": %*{ "total": accountPool.len, - "active": accountPool.len - limited.card, "limited": limited.card, "oldest": $fromUnix(oldest), "newest": $fromUnix(newest), From 7b3fcdc622628febc306cf9c0d4a33c493d690db Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 04:19:10 +0000 Subject: [PATCH 073/309] Fix guest accounts CI setup attempt 4 --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 948d284..76af329 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -47,7 +47,7 @@ jobs: sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf nimble md nimble scss - echo "${{ vars.GUEST_ACCOUNTS }}" > ./guest_accounts.jsonl + echo '${{ secrets.GUEST_ACCOUNTS }}' > ./guest_accounts.jsonl - name: Run tests run: | ./nitter & From 623424f5160b3e99e6e4b9675c8705f87fbf9d72 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 04:52:44 +0000 Subject: [PATCH 074/309] Fix outdated test --- tests/test_card.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_card.py b/tests/test_card.py index 733bd40..8da91a2 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -21,9 +21,9 @@ card = [ no_thumb = [ ['FluentAI/status/1116417904831029248', - 'Amazon’s Alexa isn’t just AI — thousands of humans are listening', - 'One of the only ways to improve Alexa is to have human beings check it for errors', - 'theverge.com'], + 'LinkedIn', + 'This link will take you to a page that’s not on LinkedIn', + 'lnkd.in'], ['Thom_Wolf/status/1122466524860702729', 'facebookresearch/fairseq', From e1838e093335fab02f36649bba7b65aa4420993f Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 05:09:21 +0000 Subject: [PATCH 075/309] Move CI workflow to buildjet --- .github/workflows/run-tests.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 76af329..ee28e33 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -10,24 +10,26 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 strategy: matrix: nim: - "1.6.10" - "1.6.x" - "2.0.x" + - "devel" steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Cache nimble id: cache-nimble - uses: actions/cache@v3 + uses: buildjet/cache@v3 with: path: ~/.nimble - key: nimble-${{ hashFiles('*.nimble') }} - restore-keys: "nimble-" + key: ${{ matrix.nim }}-nimble-${{ hashFiles('*.nimble') }} + restore-keys: | + ${{ matrix.nim }}-nimble- - uses: actions/setup-python@v4 with: python-version: "3.10" @@ -51,4 +53,4 @@ jobs: - name: Run tests run: | ./nitter & - pytest -n4 tests + pytest -n8 tests From 209f453b7998d0ac43950618d61f87efbf8c2b26 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 05:09:44 +0000 Subject: [PATCH 076/309] Purge expired accounts after parsing --- src/auth.nim | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index 8be435c..8c901e2 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -1,5 +1,5 @@ #SPDX-License-Identifier: AGPL-3.0-only -import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os] +import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os] import types import experimental/parser/guestaccount @@ -30,6 +30,16 @@ var template log(str: varargs[string, `$`]) = if enableLogging: echo "[accounts] ", str.join("") +proc snowflakeToEpoch(flake: int64): int64 = + int64(((flake shr 22) + 1288834974657) div 1000) + +proc hasExpired(account: GuestAccount): bool = + let + created = snowflakeToEpoch(account.id) + now = epochTime().int64 + daysOld = int(now - created) div (24 * 60 * 60) + return daysOld > 30 + proc getAccountPoolHealth*(): JsonNode = let now = epochTime().int @@ -42,9 +52,7 @@ proc getAccountPoolHealth*(): JsonNode = average = 0'i64 for account in accountPool: - # Twitter snowflake conversion - let created = int64(((account.id shr 22) + 1288834974657) div 1000) - + let created = snowflakeToEpoch(account.id) if created > newest: newest = created if created < oldest: @@ -188,3 +196,10 @@ proc initAccountPool*(cfg: Config; path: string) = else: echo "[accounts] ERROR: ", path, " not found. This file is required to authenticate API requests." quit 1 + + let accountsPrePurge = accountPool.len + accountPool.keepItIf(not it.hasExpired) + + log "Successfully added ", accountPool.len, " valid accounts." + if accountsPrePurge > accountPool.len: + log "Purged ", accountsPrePurge - accountPool.len, " expired accounts." From d17583286a11586c6ff5cffc43bc997e525a578e Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Nov 2023 05:44:08 +0000 Subject: [PATCH 077/309] Don't requests made before reset --- src/auth.nim | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index 8c901e2..fed7df3 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -64,12 +64,16 @@ proc getAccountPoolHealth*(): JsonNode = apiStatus = account.apis[api] reqs = apiMaxReqs[api] - apiStatus.remaining - reqsPerApi.mgetOrPut($api, 0).inc reqs - totalReqs.inc reqs - if apiStatus.limited: limited.incl account.id + # no requests made with this account and endpoint since the limit reset + if apiStatus.reset < now: + continue + + reqsPerApi.mgetOrPut($api, 0).inc reqs + totalReqs.inc reqs + if accountPool.len > 0: average = average div accountPool.len else: From e0d9dd0f9c8175fe9f5bf5aa86c0f56ccbc970a9 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 4 Nov 2023 02:56:32 +0000 Subject: [PATCH 078/309] Fix #670 --- src/auth.nim | 4 ++-- src/sass/profile/card.scss | 2 +- src/types.nim | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index fed7df3..b288c50 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -37,7 +37,7 @@ proc hasExpired(account: GuestAccount): bool = let created = snowflakeToEpoch(account.id) now = epochTime().int64 - daysOld = int(now - created) div (24 * 60 * 60) + daysOld = int(now - created) div dayInSeconds return daysOld > 30 proc getAccountPoolHealth*(): JsonNode = @@ -45,7 +45,7 @@ proc getAccountPoolHealth*(): JsonNode = var totalReqs = 0 - limited: PackedSet[BiggestInt] + limited: PackedSet[int64] reqsPerApi: Table[string, int] oldest = now.int64 newest = 0'i64 diff --git a/src/sass/profile/card.scss b/src/sass/profile/card.scss index 85878e4..46a9679 100644 --- a/src/sass/profile/card.scss +++ b/src/sass/profile/card.scss @@ -115,7 +115,7 @@ } .profile-card-tabs-name { - @include breakable; + flex-shrink: 100; } .profile-card-avatar { diff --git a/src/types.nim b/src/types.nim index 3b0d55c..9ddf283 100644 --- a/src/types.nim +++ b/src/types.nim @@ -36,7 +36,7 @@ type limitedAt*: int GuestAccount* = ref object - id*: BiggestInt + id*: int64 oauthToken*: string oauthSecret*: string pending*: int @@ -164,7 +164,7 @@ type newsletterPublication = "newsletter_publication" hidden unknown - + Card* = object kind*: CardKind url*: string From 5e188647fc5ddcc38084127f1db32f17f07fe727 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Nov 2023 14:53:35 +0000 Subject: [PATCH 079/309] Bump Nim in the ARM64 Dockerfile, add nitter user --- Dockerfile.arm64 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index fbad812..c82be8a 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -1,7 +1,7 @@ FROM alpine:3.18 as nim LABEL maintainer="setenforce@protonmail.com" -RUN apk --no-cache add gcc git libc-dev libsass-dev "nim=1.6.14-r0" nimble pcre +RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.16-r0" "nimble=0.13.1-r3" WORKDIR /src/nitter @@ -15,9 +15,11 @@ RUN nimble build -d:danger -d:lto -d:strip \ FROM alpine:3.18 WORKDIR /src/ -RUN apk --no-cache add ca-certificates pcre openssl1.1-compat +RUN apk --no-cache add pcre ca-certificates openssl1.1-compat COPY --from=nim /src/nitter/nitter ./ COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf COPY --from=nim /src/nitter/public ./public EXPOSE 8080 +RUN adduser -h /src/ -D -s /bin/sh nitter +USER nitter CMD ./nitter From eaedd2aee7be6bc3dd2dceee09dc93052d0046f4 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Nov 2023 16:38:43 +0000 Subject: [PATCH 080/309] Fix ARM64 Dockerfile versions --- Dockerfile.arm64 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index c82be8a..70024b2 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -1,7 +1,7 @@ FROM alpine:3.18 as nim LABEL maintainer="setenforce@protonmail.com" -RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.16-r0" "nimble=0.13.1-r3" +RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.14-r0" "nimble=0.13.1-r2" WORKDIR /src/nitter From c2819dab441b8ad8220b03dd0fe79f5a5d51b841 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 15 Nov 2023 10:40:21 +0000 Subject: [PATCH 081/309] Fix #1106 Closes #831 --- src/parserutils.nim | 14 ++++++++++++-- tests/test_quote.py | 2 +- tests/test_tweet.py | 11 ++++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/parserutils.nim b/src/parserutils.nim index 7cf696e..6b8263f 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -1,9 +1,17 @@ # SPDX-License-Identifier: AGPL-3.0-only -import std/[strutils, times, macros, htmlgen, options, algorithm, re] +import std/[times, macros, htmlgen, options, algorithm, re] +import std/strutils except escape import std/unicode except strip +from xmltree import escape import packedjson import types, utils, formatters +const + unicodeOpen = "\uFFFA" + unicodeClose = "\uFFFB" + xmlOpen = escape("<") + xmlClose = escape(">") + let unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})" unReplace = "$1@$2" @@ -304,7 +312,9 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entity_set"} - text = js{"text"}.getStr + text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose)) textSlice = 0..text.runeLen tweet.expandTextEntities(entities, text, textSlice) + + tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) diff --git a/tests/test_quote.py b/tests/test_quote.py index 1b458ea..4921c21 100644 --- a/tests/test_quote.py +++ b/tests/test_quote.py @@ -9,7 +9,7 @@ text = [ What are we doing wrong? reuters.com/article/us-norwa…"""], ['nim_lang/status/1491461266849808397#m', - 'Nim language', '@nim_lang', + 'Nim', '@nim_lang', """What's better than Nim 1.6.0? Nim 1.6.2 :) diff --git a/tests/test_tweet.py b/tests/test_tweet.py index 7a3c4ed..ac89782 100644 --- a/tests/test_tweet.py +++ b/tests/test_tweet.py @@ -35,7 +35,16 @@ multiline = [ CALM AND CLICHÉ - ON"""] + ON"""], + [1718660434457239868, 'WebDesignMuseum', + """ +Happy 32nd Birthday HTML tags! + +On October 29, 1991, the internet pioneer, Tim Berners-Lee, published a document entitled HTML Tags. + +The document contained a description of the first 18 HTML tags: , <nextid>, <a>, <isindex>, <plaintext>, <listing>, <p>, <h1>…<h6>, <address>, <hp1>, <hp2>…, <dl>, <dt>, <dd>, <ul>, <li>,<menu> and <dir>. The design of the first version of HTML language was influenced by the SGML universal markup language. + +#WebDesignHistory"""] ] link = [ From 06ab1ea2e7341a239447e0ca7d1e9c6246b896c6 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 15 Nov 2023 11:11:56 +0000 Subject: [PATCH 082/309] Enable disabled tests --- tests/test_tweet.py | 58 +++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/tests/test_tweet.py b/tests/test_tweet.py index ac89782..839e6c5 100644 --- a/tests/test_tweet.py +++ b/tests/test_tweet.py @@ -1,4 +1,4 @@ -from base import BaseTestCase, Tweet, get_timeline_tweet +from base import BaseTestCase, Tweet, Conversation, get_timeline_tweet from parameterized import parameterized # image = tweet + 'div.attachments.media-body > div > div > a > div > img' @@ -83,22 +83,18 @@ retweet = [ [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr'] ] -# reply = [ -# ['mobile_test/with_replies', 15] -# ] - class TweetTest(BaseTestCase): - # @parameterized.expand(timeline) - # def test_timeline(self, index, fullname, username, date, tid, text): - # self.open_nitter(username) - # tweet = get_timeline_tweet(index) - # self.assert_exact_text(fullname, tweet.fullname) - # self.assert_exact_text('@' + username, tweet.username) - # self.assert_exact_text(date, tweet.date) - # self.assert_text(text, tweet.text) - # permalink = self.find_element(tweet.date + ' a') - # self.assertIn(tid, permalink.get_attribute('href')) + @parameterized.expand(timeline) + def test_timeline(self, index, fullname, username, date, tid, text): + self.open_nitter(username) + tweet = get_timeline_tweet(index) + self.assert_exact_text(fullname, tweet.fullname) + self.assert_exact_text('@' + username, tweet.username) + self.assert_exact_text(date, tweet.date) + self.assert_text(text, tweet.text) + permalink = self.find_element(tweet.date + ' a') + self.assertIn(tid, permalink.get_attribute('href')) @parameterized.expand(status) def test_status(self, tid, fullname, username, date, text): @@ -112,18 +108,18 @@ class TweetTest(BaseTestCase): @parameterized.expand(multiline) def test_multiline_formatting(self, tid, username, text): self.open_nitter(f'{username}/status/{tid}') - self.assert_text(text.strip('\n'), '.main-tweet') + self.assert_text(text.strip('\n'), Conversation.main) @parameterized.expand(emoji) def test_emoji(self, tweet, text): self.open_nitter(tweet) - self.assert_text(text, '.main-tweet') + self.assert_text(text, Conversation.main) @parameterized.expand(link) def test_link(self, tweet, links): self.open_nitter(tweet) for link in links: - self.assert_text(link, '.main-tweet') + self.assert_text(link, Conversation.main) @parameterized.expand(username) def test_username(self, tweet, usernames): @@ -132,22 +128,22 @@ class TweetTest(BaseTestCase): link = self.find_link_text(f'@{un}') self.assertIn(f'/{un}', link.get_property('href')) - # @parameterized.expand(retweet) - # def test_retweet(self, index, url, retweet_by, fullname, username, text): - # self.open_nitter(url) - # tweet = get_timeline_tweet(index) - # self.assert_text(f'{retweet_by} retweeted', tweet.retweet) - # self.assert_text(text, tweet.text) - # self.assert_exact_text(fullname, tweet.fullname) - # self.assert_exact_text(username, tweet.username) + @parameterized.expand(retweet) + def test_retweet(self, index, url, retweet_by, fullname, username, text): + self.open_nitter(url) + tweet = get_timeline_tweet(index) + self.assert_text(f'{retweet_by} retweeted', tweet.retweet) + self.assert_text(text, tweet.text) + self.assert_exact_text(fullname, tweet.fullname) + self.assert_exact_text(username, tweet.username) @parameterized.expand(invalid) def test_invalid_id(self, tweet): self.open_nitter(tweet) self.assert_text('Tweet not found', '.error-panel') - # @parameterized.expand(reply) - # def test_thread(self, tweet, num): - # self.open_nitter(tweet) - # thread = self.find_element(f'.timeline > div:nth-child({num})') - # self.assertIn(thread.get_attribute('class'), 'thread-line') + #@parameterized.expand(reply) + #def test_thread(self, tweet, num): + #self.open_nitter(tweet) + #thread = self.find_element(f'.timeline > div:nth-child({num})') + #self.assertIn(thread.get_attribute('class'), 'thread-line') From 4dac9f0798b885130b01f9a5a5535c729d351467 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 25 Nov 2023 05:31:15 +0000 Subject: [PATCH 083/309] Add simple job_details card support --- src/experimental/parser/graphql.nim | 4 ++-- src/experimental/parser/unifiedcard.nim | 11 +++++++++ src/experimental/parser/user.nim | 7 +++++- src/experimental/types/graphuser.nim | 4 ++-- src/experimental/types/timeline.nim | 4 ++-- src/experimental/types/unifiedcard.nim | 32 ++++++++++++++++++++----- src/parser.nim | 4 ++-- src/parserutils.nim | 9 +++---- src/types.nim | 1 + src/utils.nim | 5 ++-- 10 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index b9da7c4..0e9a678 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -12,7 +12,7 @@ proc parseGraphUser*(json: string): User = if raw.data.userResult.result.unavailableReason.get("") == "Suspended": return User(suspended: true) - result = toUser raw.data.userResult.result.legacy + result = raw.data.userResult.result.legacy result.id = raw.data.userResult.result.restId result.verified = result.verified or raw.data.userResult.result.isBlueVerified @@ -30,7 +30,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] = of TimelineTimelineItem: let userResult = entry.content.itemContent.userResults.result if userResult.restId.len > 0: - result.content.add toUser userResult.legacy + result.content.add userResult.legacy of TimelineTimelineCursor: if entry.content.cursorType == "Bottom": result.bottom = entry.content.value diff --git a/src/experimental/parser/unifiedcard.nim b/src/experimental/parser/unifiedcard.nim index c9af437..4a50e48 100644 --- a/src/experimental/parser/unifiedcard.nim +++ b/src/experimental/parser/unifiedcard.nim @@ -1,5 +1,6 @@ import std/[options, tables, strutils, strformat, sugar] import jsony +import user import ../types/unifiedcard from ../../types import Card, CardKind, Video from ../../utils import twimg, https @@ -27,6 +28,14 @@ proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card) result.text = data.topicDetail.title result.dest = "Topic" +proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) = + data.destination.parseDestination(card, result) + + result.kind = jobDetails + result.title = data.title + result.text = data.shortDescriptionText + result.dest = &"@{data.profileUser.username} · {data.location}" + proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) = let app = card.appStoreData[data.appId][0] @@ -84,6 +93,8 @@ proc parseUnifiedCard*(json: string): Card = component.parseMedia(card, result) of buttonGroup: discard + of jobDetails: + component.data.parseJobDetails(card, result) of ComponentType.hidden: result.kind = CardKind.hidden of ComponentType.unknown: diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 5962a87..78f596e 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -68,6 +68,11 @@ proc toUser*(raw: RawUser): User = result.expandUserEntities(raw) +proc parseHook*(s: string; i: var int; v: var User) = + var u: RawUser + parseHook(s, i, u) + v = toUser u + proc parseUser*(json: string; username=""): User = handleErrors: case error.code @@ -75,7 +80,7 @@ proc parseUser*(json: string; username=""): User = of userNotFound: return else: echo "[error - parseUser]: ", error - result = toUser json.fromJson(RawUser) + result = json.fromJson(User) proc parseUsers*(json: string; after=""): Result[User] = result = Result[User](beginning: after.len == 0) diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index c30eed9..08100f9 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -1,5 +1,5 @@ import options -import user +from ../../types import User type GraphUser* = object @@ -9,7 +9,7 @@ type result*: UserResult UserResult = object - legacy*: RawUser + legacy*: User restId*: string isBlueVerified*: bool unavailableReason*: Option[string] diff --git a/src/experimental/types/timeline.nim b/src/experimental/types/timeline.nim index 28239ad..5ce6d9f 100644 --- a/src/experimental/types/timeline.nim +++ b/src/experimental/types/timeline.nim @@ -1,5 +1,5 @@ import std/tables -import user +from ../../types import User type Search* = object @@ -7,7 +7,7 @@ type timeline*: Timeline GlobalObjects = object - users*: Table[string, RawUser] + users*: Table[string, User] Timeline = object instructions*: seq[Instructions] diff --git a/src/experimental/types/unifiedcard.nim b/src/experimental/types/unifiedcard.nim index 6e83cad..e540a64 100644 --- a/src/experimental/types/unifiedcard.nim +++ b/src/experimental/types/unifiedcard.nim @@ -1,7 +1,10 @@ -import options, tables -from ../../types import VideoType, VideoVariant +import std/[options, tables, times] +import jsony +from ../../types import VideoType, VideoVariant, User type + Text* = distinct string + UnifiedCard* = object componentObjects*: Table[string, Component] destinationObjects*: Table[string, Destination] @@ -13,6 +16,7 @@ type media swipeableMedia buttonGroup + jobDetails appStoreDetails twitterListDetails communityDetails @@ -29,12 +33,15 @@ type appId*: string mediaId*: string destination*: string + location*: string title*: Text subtitle*: Text name*: Text memberCount*: int mediaList*: seq[MediaItem] topicDetail*: tuple[title: Text] + profileUser*: User + shortDescriptionText*: string MediaItem* = object id*: string @@ -69,12 +76,9 @@ type title*: Text category*: Text - Text = object - content: string - TypeField = Component | Destination | MediaEntity | AppStoreData -converter fromText*(text: Text): string = text.content +converter fromText*(text: Text): string = string(text) proc renameHook*(v: var TypeField; fieldName: var string) = if fieldName == "type": @@ -86,6 +90,7 @@ proc enumHook*(s: string; v: var ComponentType) = of "media": media of "swipeable_media": swipeableMedia of "button_group": buttonGroup + of "job_details": jobDetails of "app_store_details": appStoreDetails of "twitter_list_details": twitterListDetails of "community_details": communityDetails @@ -106,3 +111,18 @@ proc enumHook*(s: string; v: var MediaType) = of "photo": photo of "model3d": model3d else: echo "ERROR: Unknown enum value (MediaType): ", s; photo + +proc parseHook*(s: string; i: var int; v: var DateTime) = + var str: string + parseHook(s, i, str) + v = parse(str, "yyyy-MM-dd hh:mm:ss") + +proc parseHook*(s: string; i: var int; v: var Text) = + if s[i] == '"': + var str: string + parseHook(s, i, str) + v = Text(str) + else: + var t: tuple[content: string] + parseHook(s, i, t) + v = Text(t.content) diff --git a/src/parser.nim b/src/parser.nim index fcee13f..d7ba613 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -219,8 +219,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = ) ) - result.expandTweetEntities(js) - # fix for pinned threads if result.hasThread and result.threadId == 0: result.threadId = js{"self_thread", "id_str"}.getId @@ -254,6 +252,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = else: result.card = some parseCard(jsCard, js{"entities", "urls"}) + result.expandTweetEntities(js) + with jsMedia, js{"extended_entities", "media"}: for m in jsMedia: case m{"type"}.getStr diff --git a/src/parserutils.nim b/src/parserutils.nim index 6b8263f..00ea6f4 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -246,7 +246,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) = .replacef(htRegex, htReplace) proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int]; - replyTo=""; hasQuote=false) = + replyTo=""; hasRedundantLink=false) = let hasCard = tweet.card.isSome var replacements = newSeq[ReplaceSlice]() @@ -257,7 +257,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic if urlStr.len == 0 or urlStr notin text: continue - replacements.extractUrls(u, textSlice.b, hideTwitter = hasQuote) + replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) if hasCard and u{"url"}.getStr == get(tweet.card).url: get(tweet.card).url = u{"expanded_url"}.getStr @@ -297,9 +297,10 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entities"} - hasQuote = js{"is_quote_status"}.getBool textRange = js{"display_text_range"} textSlice = textRange{0}.getInt .. textRange{1}.getInt + hasQuote = js{"is_quote_status"}.getBool + hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails var replyTo = "" if tweet.replyId != 0: @@ -307,7 +308,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = replyTo = reply.getStr tweet.reply.add replyTo - tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote) + tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let diff --git a/src/types.nim b/src/types.nim index 9ddf283..bc791b1 100644 --- a/src/types.nim +++ b/src/types.nim @@ -162,6 +162,7 @@ type imageDirectMessage = "image_direct_message" audiospace = "audiospace" newsletterPublication = "newsletter_publication" + jobDetails = "job_details" hidden unknown diff --git a/src/utils.nim b/src/utils.nim index 9002bbf..c96a6dd 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -16,7 +16,8 @@ const "twimg.com", "abs.twimg.com", "pbs.twimg.com", - "video.twimg.com" + "video.twimg.com", + "x.com" ] proc setHmacKey*(key: string) = @@ -57,4 +58,4 @@ proc isTwitterUrl*(uri: Uri): bool = uri.hostname in twitterDomains proc isTwitterUrl*(url: string): bool = - parseUri(url).hostname in twitterDomains + isTwitterUrl(parseUri(url)) From d6be08d093bcde38286268b789395cf7a99e67ed Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 25 Nov 2023 05:53:13 +0000 Subject: [PATCH 084/309] Fix jobDetails error on old Nim versions --- src/experimental/parser/unifiedcard.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/experimental/parser/unifiedcard.nim b/src/experimental/parser/unifiedcard.nim index 4a50e48..1f7b825 100644 --- a/src/experimental/parser/unifiedcard.nim +++ b/src/experimental/parser/unifiedcard.nim @@ -31,7 +31,7 @@ proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card) proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) = data.destination.parseDestination(card, result) - result.kind = jobDetails + result.kind = CardKind.jobDetails result.title = data.title result.text = data.shortDescriptionText result.dest = &"@{data.profileUser.username} · {data.location}" @@ -93,7 +93,7 @@ proc parseUnifiedCard*(json: string): Card = component.parseMedia(card, result) of buttonGroup: discard - of jobDetails: + of ComponentType.jobDetails: component.data.parseJobDetails(card, result) of ComponentType.hidden: result.kind = CardKind.hidden From f8254c2f0f3bfacb754d7ad69be9b55258c8337c Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 25 Nov 2023 10:06:12 +0000 Subject: [PATCH 085/309] Add support for business and gov verification Also improve icon rendering on Firefox --- src/consts.nim | 10 ++++------ src/experimental/parser/graphql.nim | 5 +++-- src/experimental/parser/unifiedcard.nim | 3 +-- src/experimental/parser/user.nim | 2 +- src/experimental/types/user.nim | 4 ++-- src/parser.nim | 6 +++--- src/redis_cache.nim | 1 + src/sass/include/_variables.scss | 2 ++ src/sass/index.scss | 21 ++++++++++++++++++--- src/sass/search.scss | 2 ++ src/types.nim | 12 ++++++++---- src/views/general.nim | 2 +- src/views/renderutils.nim | 17 ++++++++++++----- src/views/tweet.nim | 3 +-- 14 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/consts.nim b/src/consts.nim index d3a3d80..e1c35e6 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -29,12 +29,10 @@ const "include_cards": "1", "include_entities": "1", "include_profile_interstitial_type": "0", - "include_quote_count": "1", - "include_reply_count": "1", - "include_user_entities": "1", - "include_ext_reply_count": "1", - "include_ext_is_blue_verified": "1", - # "include_ext_verified_type": "1", + "include_quote_count": "0", + "include_reply_count": "0", + "include_user_entities": "0", + "include_ext_reply_count": "0", "include_ext_media_color": "0", "cards_platform": "Web-13", "tweet_mode": "extended", diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 0e9a678..c7f115f 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -1,7 +1,7 @@ import options import jsony import user, ../types/[graphuser, graphlistmembers] -from ../../types import User, Result, Query, QueryKind +from ../../types import User, VerifiedType, Result, Query, QueryKind proc parseGraphUser*(json: string): User = if json.len == 0 or json[0] != '{': @@ -14,7 +14,8 @@ proc parseGraphUser*(json: string): User = result = raw.data.userResult.result.legacy result.id = raw.data.userResult.result.restId - result.verified = result.verified or raw.data.userResult.result.isBlueVerified + if result.verifiedType == none and raw.data.userResult.result.isBlueVerified: + result.verifiedType = blue proc parseGraphListMembers*(json, cursor: string): Result[User] = result = Result[User]( diff --git a/src/experimental/parser/unifiedcard.nim b/src/experimental/parser/unifiedcard.nim index 1f7b825..a112974 100644 --- a/src/experimental/parser/unifiedcard.nim +++ b/src/experimental/parser/unifiedcard.nim @@ -1,7 +1,6 @@ import std/[options, tables, strutils, strformat, sugar] import jsony -import user -import ../types/unifiedcard +import user, ../types/unifiedcard from ../../types import Card, CardKind, Video from ../../utils import twimg, https diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 78f596e..07e0477 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -56,7 +56,7 @@ proc toUser*(raw: RawUser): User = tweets: raw.statusesCount, likes: raw.favouritesCount, media: raw.mediaCount, - verified: raw.verified or raw.extIsBlueVerified, + verifiedType: raw.verifiedType, protected: raw.protected, joinDate: parseTwitterDate(raw.createdAt), banner: getBanner(raw), diff --git a/src/experimental/types/user.nim b/src/experimental/types/user.nim index 39331a0..7dc0194 100644 --- a/src/experimental/types/user.nim +++ b/src/experimental/types/user.nim @@ -1,5 +1,6 @@ import options import common +from ../../types import VerifiedType type RawUser* = object @@ -15,8 +16,7 @@ type favouritesCount*: int statusesCount*: int mediaCount*: int - verified*: bool - extIsBlueVerified*: bool + verifiedType*: VerifiedType protected*: bool profileLinkColor*: string profileBannerUrl*: string diff --git a/src/parser.nim b/src/parser.nim index d7ba613..a7bf89d 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -21,7 +21,7 @@ proc parseUser(js: JsonNode; id=""): User = tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, - verified: js{"verified"}.getBool or js{"ext_is_blue_verified"}.getBool, + verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")), protected: js{"protected"}.getBool, joinDate: js{"created_at"}.getTime ) @@ -34,8 +34,8 @@ proc parseGraphUser(js: JsonNode): User = user = ? js{"user_results", "result"} result = parseUser(user{"legacy"}) - if "is_blue_verified" in user: - result.verified = user{"is_blue_verified"}.getBool() + if result.verifiedType == none and user{"is_blue_verified"}.getBool(false): + result.verifiedType = blue proc parseGraphList*(js: JsonNode): List = if js.isNull: return diff --git a/src/redis_cache.nim b/src/redis_cache.nim index a8b5ff8..1d77cca 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -52,6 +52,7 @@ proc initRedisPool*(cfg: Config) {.async.} = await migrate("profileDates", "p:*") await migrate("profileStats", "p:*") await migrate("userType", "p:*") + await migrate("verifiedType", "p:*") pool.withAcquire(r): # optimize memory usage for user ID buckets diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss index 0f81235..0c95ff6 100644 --- a/src/sass/include/_variables.scss +++ b/src/sass/include/_variables.scss @@ -28,6 +28,8 @@ $more_replies_dots: #AD433B; $error_red: #420A05; $verified_blue: #1DA1F2; +$verified_business: #FAC82B; +$verified_government: #C1B6A4; $icon_text: $fg_color; $tab: $fg_color; diff --git a/src/sass/index.scss b/src/sass/index.scss index 9e2e347..6cab48e 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -39,6 +39,8 @@ body { --error_red: #{$error_red}; --verified_blue: #{$verified_blue}; + --verified_business: #{$verified_business}; + --verified_government: #{$verified_government}; --icon_text: #{$icon_text}; --tab: #{$fg_color}; @@ -141,17 +143,30 @@ ul { .verified-icon { color: var(--icon_text); - background-color: var(--verified_blue); border-radius: 50%; flex-shrink: 0; margin: 2px 0 3px 3px; - padding-top: 2px; - height: 12px; + padding-top: 3px; + height: 11px; width: 14px; font-size: 8px; display: inline-block; text-align: center; vertical-align: middle; + + &.blue { + background-color: var(--verified_blue); + } + + &.business { + color: var(--bg_panel); + background-color: var(--verified_business); + } + + &.government { + color: var(--bg_panel); + background-color: var(--verified_government); + } } @media(max-width: 600px) { diff --git a/src/sass/search.scss b/src/sass/search.scss index 0311fb0..f70f7ea 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -14,6 +14,8 @@ button { margin: 0 2px 0 0; height: 23px; + display: flex; + align-items: center; } .pref-input { diff --git a/src/types.nim b/src/types.nim index bc791b1..ddbebdf 100644 --- a/src/types.nim +++ b/src/types.nim @@ -10,9 +10,7 @@ type BadClientError* = object of CatchableError TimelineKind* {.pure.} = enum - tweets - replies - media + tweets, replies, media Api* {.pure.} = enum tweetDetail @@ -63,6 +61,12 @@ type tweetUnavailable = 421 tweetCensored = 422 + VerifiedType* = enum + none = "None" + blue = "Blue" + business = "Business" + government = "Government" + User* = object id*: string username*: string @@ -78,7 +82,7 @@ type tweets*: int likes*: int media*: int - verified*: bool + verifiedType*: VerifiedType protected*: bool suspended*: bool joinDate*: DateTime diff --git a/src/views/general.nim b/src/views/general.nim index 5e96d02..87d30f2 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,7 +52,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=18") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") if theme.len > 0: diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 9dffdcb..451ddfb 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -23,6 +23,13 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = if text.len > 0: text " " & text +template verifiedIcon*(user: User): untyped {.dirty.} = + if user.verifiedType != none: + let lower = ($user.verifiedType).toLowerAscii() + icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") + else: + text "" + proc linkUser*(user: User, class=""): VNode = let isName = "username" notin class @@ -32,11 +39,11 @@ proc linkUser*(user: User, class=""): VNode = buildHtml(a(href=href, class=class, title=nameText)): text nameText - if isName and user.verified: - icon "ok", class="verified-icon", title="Verified account" - if isName and user.protected: - text " " - icon "lock", title="Protected account" + if isName: + verifiedIcon(user) + if user.protected: + text " " + icon "lock", title="Protected account" proc linkText*(text: string; class=""): VNode = let url = if "http" notin text: https & text else: text diff --git a/src/views/tweet.nim b/src/views/tweet.nim index f47ae9a..2fe4ac9 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -200,8 +200,7 @@ proc renderAttribution(user: User; prefs: Prefs): VNode = buildHtml(a(class="attribution", href=("/" & user.username))): renderMiniAvatar(user, prefs) strong: text user.fullname - if user.verified: - icon "ok", class="verified-icon", title="Verified account" + verifiedIcon(user) proc renderMediaTags(tags: seq[User]): VNode = buildHtml(tdiv(class="media-tag-block")): From a9740fec8b2d9d4469322cc66d3f3547e0b6ccdb Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 25 Nov 2023 10:11:57 +0000 Subject: [PATCH 086/309] Fix compilation with old Nim again --- src/experimental/parser/graphql.nim | 2 +- src/parser.nim | 2 +- src/views/renderutils.nim | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index c7f115f..69837ab 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -14,7 +14,7 @@ proc parseGraphUser*(json: string): User = result = raw.data.userResult.result.legacy result.id = raw.data.userResult.result.restId - if result.verifiedType == none and raw.data.userResult.result.isBlueVerified: + if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified: result.verifiedType = blue proc parseGraphListMembers*(json, cursor: string): Result[User] = diff --git a/src/parser.nim b/src/parser.nim index a7bf89d..f2547e4 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -34,7 +34,7 @@ proc parseGraphUser(js: JsonNode): User = user = ? js{"user_results", "result"} result = parseUser(user{"legacy"}) - if result.verifiedType == none and user{"is_blue_verified"}.getBool(false): + if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false): result.verifiedType = blue proc parseGraphList*(js: JsonNode): List = diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 451ddfb..f298fad 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -24,7 +24,7 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = text " " & text template verifiedIcon*(user: User): untyped {.dirty.} = - if user.verifiedType != none: + if user.verifiedType != VerifiedType.none: let lower = ($user.verifiedType).toLowerAscii() icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") else: From 583c858cdf3486451ed6a0627640844f27009dbe Mon Sep 17 00:00:00 2001 From: blankie <blankie@nixnetmail.com> Date: Sun, 3 Dec 2023 08:54:24 +0000 Subject: [PATCH 087/309] Fix search queries in user search RSS feeds (#1126) Fixes #992 --- src/parser.nim | 2 +- src/routes/rss.nim | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index f2547e4..ec856a6 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -32,7 +32,7 @@ proc parseGraphUser(js: JsonNode): User = var user = js{"user_result", "result"} if user.isNull: user = ? js{"user_results", "result"} - result = parseUser(user{"legacy"}) + result = parseUser(user{"legacy"}, user{"rest_id"}.getStr) if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false): result.verifiedType = blue diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 6c77992..447f4ad 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -110,7 +110,7 @@ proc createRssRouter*(cfg: Config) = case tab of "with_replies": getReplyQuery(name) of "media": getMediaQuery(name) - # of "search": initQuery(params(request), name=name) + of "search": initQuery(params(request), name=name) else: Query(fromUser: @[name]) let searchKey = if tab != "search": "" From 52db03b73ad5f83f67c83ab197ae3b20a2523d39 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Fri, 12 Jan 2024 03:47:06 +0100 Subject: [PATCH 088/309] Fix broken video playback by forcing fmp4 --- src/utils.nim | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils.nim b/src/utils.nim index c96a6dd..ede9aed 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -31,7 +31,9 @@ proc getHmac*(data: string): string = proc getVidUrl*(link: string): string = if link.len == 0: return - let sig = getHmac(link) + let + link = link.replace("cmaf", "fmp4") + sig = getHmac(link) if base64Media: &"/video/enc/{sig}/{encode(link, safe=true)}" else: From cdff5e9b1c1ff9d61d991f95c45d0588a0d90434 Mon Sep 17 00:00:00 2001 From: jackyzy823 <jackyzy823@gmail.com> Date: Thu, 22 Feb 2024 07:10:54 +0800 Subject: [PATCH 089/309] Fix for #1147, Proxy for audio URL and upgrade hls.js (#1178) * Revert "Fix broken video playback by forcing fmp4" This reverts commit 52db03b73ad5f83f67c83ab197ae3b20a2523d39. * Fix audio url in video m3u8 * Upgrade hls.js to 1.5.1 and use full version --- public/js/hls.light.min.js | 5 ----- public/js/hls.min.js | 5 +++++ src/formatters.nim | 2 ++ src/utils.nim | 4 +--- src/views/general.nim | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) delete mode 100644 public/js/hls.light.min.js create mode 100644 public/js/hls.min.js diff --git a/public/js/hls.light.min.js b/public/js/hls.light.min.js deleted file mode 100644 index 5a1fd1d..0000000 --- a/public/js/hls.light.min.js +++ /dev/null @@ -1,5 +0,0 @@ -// @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0 -// @source https://github.com/video-dev/hls.js -// @version v1.2.9 -"undefined"!=typeof window&&function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Hls=t():e.Hls=t()}(this,(()=>(()=>{var e={21:(e,t,r)=>{"use strict";r.r(t),r.d(t,{default:()=>u});var i=function(){function e(e,t){this.subtle=void 0,this.aesIV=void 0,this.subtle=e,this.aesIV=t}return e.prototype.decrypt=function(e,t){return this.subtle.decrypt({name:"AES-CBC",iv:this.aesIV},t,e)},e}(),n=function(){function e(e,t){this.subtle=void 0,this.key=void 0,this.subtle=e,this.key=t}return e.prototype.expandKey=function(){return this.subtle.importKey("raw",this.key,{name:"AES-CBC"},!1,["encrypt","decrypt"])},e}(),a=r(145),s=function(){function e(){this.rcon=[0,1,2,4,8,16,32,64,128,27,54],this.subMix=[new Uint32Array(256),new Uint32Array(256),new Uint32Array(256),new Uint32Array(256)],this.invSubMix=[new Uint32Array(256),new Uint32Array(256),new Uint32Array(256),new Uint32Array(256)],this.sBox=new Uint32Array(256),this.invSBox=new Uint32Array(256),this.key=new Uint32Array(0),this.ksRows=0,this.keySize=0,this.keySchedule=void 0,this.invKeySchedule=void 0,this.initTable()}var t=e.prototype;return t.uint8ArrayToUint32Array_=function(e){for(var t=new DataView(e),r=new Uint32Array(4),i=0;i<4;i++)r[i]=t.getUint32(4*i);return r},t.initTable=function(){var e=this.sBox,t=this.invSBox,r=this.subMix,i=r[0],n=r[1],a=r[2],s=r[3],o=this.invSubMix,l=o[0],u=o[1],d=o[2],h=o[3],f=new Uint32Array(256),c=0,v=0,g=0;for(g=0;g<256;g++)f[g]=g<128?g<<1:g<<1^283;for(g=0;g<256;g++){var p=v^v<<1^v<<2^v<<3^v<<4;p=p>>>8^255&p^99,e[c]=p,t[p]=c;var m=f[c],y=f[m],E=f[y],T=257*f[p]^16843008*p;i[c]=T<<24|T>>>8,n[c]=T<<16|T>>>16,a[c]=T<<8|T>>>24,s[c]=T,T=16843009*E^65537*y^257*m^16843008*c,l[p]=T<<24|T>>>8,u[p]=T<<16|T>>>16,d[p]=T<<8|T>>>24,h[p]=T,c?(c=m^f[f[f[E^m]]],v^=f[f[v]]):c=v=1}},t.expandKey=function(e){for(var t=this.uint8ArrayToUint32Array_(e),r=!0,i=0;i<t.length&&r;)r=t[i]===this.key[i],i++;if(!r){this.key=t;var n=this.keySize=t.length;if(4!==n&&6!==n&&8!==n)throw new Error("Invalid aes key size="+n);var a,s,o,l,u=this.ksRows=4*(n+6+1),d=this.keySchedule=new Uint32Array(u),h=this.invKeySchedule=new Uint32Array(u),f=this.sBox,c=this.rcon,v=this.invSubMix,g=v[0],p=v[1],m=v[2],y=v[3];for(a=0;a<u;a++)a<n?o=d[a]=t[a]:(l=o,a%n==0?(l=f[(l=l<<8|l>>>24)>>>24]<<24|f[l>>>16&255]<<16|f[l>>>8&255]<<8|f[255&l],l^=c[a/n|0]<<24):n>6&&a%n==4&&(l=f[l>>>24]<<24|f[l>>>16&255]<<16|f[l>>>8&255]<<8|f[255&l]),d[a]=o=(d[a-n]^l)>>>0);for(s=0;s<u;s++)a=u-s,l=3&s?d[a]:d[a-4],h[s]=s<4||a<=4?l:g[f[l>>>24]]^p[f[l>>>16&255]]^m[f[l>>>8&255]]^y[f[255&l]],h[s]=h[s]>>>0}},t.networkToHostOrderSwap=function(e){return e<<24|(65280&e)<<8|(16711680&e)>>8|e>>>24},t.decrypt=function(e,t,r){for(var i,n,a,s,o,l,u,d,h,f,c,v,g,p,m=this.keySize+6,y=this.invKeySchedule,E=this.invSBox,T=this.invSubMix,S=T[0],b=T[1],L=T[2],A=T[3],D=this.uint8ArrayToUint32Array_(r),R=D[0],k=D[1],_=D[2],x=D[3],I=new Int32Array(e),w=new Int32Array(I.length),C=this.networkToHostOrderSwap;t<I.length;){for(h=C(I[t]),f=C(I[t+1]),c=C(I[t+2]),v=C(I[t+3]),o=h^y[0],l=v^y[1],u=c^y[2],d=f^y[3],g=4,p=1;p<m;p++)i=S[o>>>24]^b[l>>16&255]^L[u>>8&255]^A[255&d]^y[g],n=S[l>>>24]^b[u>>16&255]^L[d>>8&255]^A[255&o]^y[g+1],a=S[u>>>24]^b[d>>16&255]^L[o>>8&255]^A[255&l]^y[g+2],s=S[d>>>24]^b[o>>16&255]^L[l>>8&255]^A[255&u]^y[g+3],o=i,l=n,u=a,d=s,g+=4;i=E[o>>>24]<<24^E[l>>16&255]<<16^E[u>>8&255]<<8^E[255&d]^y[g],n=E[l>>>24]<<24^E[u>>16&255]<<16^E[d>>8&255]<<8^E[255&o]^y[g+1],a=E[u>>>24]<<24^E[d>>16&255]<<16^E[o>>8&255]<<8^E[255&l]^y[g+2],s=E[d>>>24]<<24^E[o>>16&255]<<16^E[l>>8&255]<<8^E[255&u]^y[g+3],w[t]=C(i^R),w[t+1]=C(s^k),w[t+2]=C(a^_),w[t+3]=C(n^x),R=h,k=f,_=c,x=v,t+=4}return w.buffer},e}(),o=r(93),l=r(63),u=function(){function e(e,t,r){var i=(void 0===r?{}:r).removePKCS7Padding,n=void 0===i||i;if(this.logEnabled=!0,this.observer=void 0,this.config=void 0,this.removePKCS7Padding=void 0,this.subtle=null,this.softwareDecrypter=null,this.key=null,this.fastAesKey=null,this.remainderData=null,this.currentIV=null,this.currentResult=null,this.observer=e,this.config=t,this.removePKCS7Padding=n,n)try{var a=self.crypto;a&&(this.subtle=a.subtle||a.webkitSubtle)}catch(e){}null===this.subtle&&(this.config.enableSoftwareAES=!0)}var t=e.prototype;return t.destroy=function(){this.observer=null},t.isSync=function(){return this.config.enableSoftwareAES},t.flush=function(){var e=this.currentResult;if(e){var t,r,i,n=new Uint8Array(e);return this.reset(),this.removePKCS7Padding?(i=(r=(t=n).byteLength)&&new DataView(t.buffer).getUint8(r-1))?(0,a.sliceUint8)(t,0,r-i):t:n}this.reset()},t.reset=function(){this.currentResult=null,this.currentIV=null,this.remainderData=null,this.softwareDecrypter&&(this.softwareDecrypter=null)},t.decrypt=function(e,t,r,i){if(this.config.enableSoftwareAES){this.softwareDecrypt(new Uint8Array(e),t,r);var n=this.flush();n&&i(n.buffer)}else this.webCryptoDecrypt(new Uint8Array(e),t,r).then(i)},t.softwareDecrypt=function(e,t,r){var i=this.currentIV,n=this.currentResult,o=this.remainderData;this.logOnce("JS AES decrypt"),o&&(e=(0,l.appendUint8Array)(o,e),this.remainderData=null);var u=this.getValidChunk(e);if(!u.length)return null;i&&(r=i);var d=this.softwareDecrypter;d||(d=this.softwareDecrypter=new s),d.expandKey(t);var h=n;return this.currentResult=d.decrypt(u.buffer,0,r),this.currentIV=(0,a.sliceUint8)(u,-16).buffer,h||null},t.webCryptoDecrypt=function(e,t,r){var a=this,s=this.subtle;return this.key===t&&this.fastAesKey||(this.key=t,this.fastAesKey=new n(s,t)),this.fastAesKey.expandKey().then((function(t){return s?new i(s,r).decrypt(e.buffer,t):Promise.reject(new Error("web crypto not initialized"))})).catch((function(i){return a.onWebCryptoError(i,e,t,r)}))},t.onWebCryptoError=function(e,t,r,i){return o.logger.warn("[decrypter.ts]: WebCrypto Error, disable WebCrypto API:",e),this.config.enableSoftwareAES=!0,this.logEnabled=!0,this.softwareDecrypt(t,r,i)},t.getValidChunk=function(e){var t=e,r=e.length-e.length%16;return r!==e.length&&(t=(0,a.sliceUint8)(e,0,r),this.remainderData=(0,a.sliceUint8)(e,r)),t},t.logOnce=function(e){this.logEnabled&&(o.logger.log("[decrypter.ts]: "+e),this.logEnabled=!1)},e}()},181:(e,t,r)=>{"use strict";r.r(t),r.d(t,{canParse:()=>l,decodeFrame:()=>c,getID3Data:()=>s,getID3Frames:()=>f,getTimeStamp:()=>u,isFooter:()=>a,isHeader:()=>n,isTimeStampFrame:()=>d,testables:()=>E,utf8ArrayToStr:()=>y});var i,n=function(e,t){return t+10<=e.length&&73===e[t]&&68===e[t+1]&&51===e[t+2]&&e[t+3]<255&&e[t+4]<255&&e[t+6]<128&&e[t+7]<128&&e[t+8]<128&&e[t+9]<128},a=function(e,t){return t+10<=e.length&&51===e[t]&&68===e[t+1]&&73===e[t+2]&&e[t+3]<255&&e[t+4]<255&&e[t+6]<128&&e[t+7]<128&&e[t+8]<128&&e[t+9]<128},s=function(e,t){for(var r=t,i=0;n(e,t);)i+=10,i+=o(e,t+6),a(e,t+10)&&(i+=10),t+=i;if(i>0)return e.subarray(r,r+i)},o=function(e,t){var r=0;return r=(127&e[t])<<21,r|=(127&e[t+1])<<14,(r|=(127&e[t+2])<<7)|127&e[t+3]},l=function(e,t){return n(e,t)&&o(e,t+6)+10<=e.length-t},u=function(e){for(var t=f(e),r=0;r<t.length;r++){var i=t[r];if(d(i))return m(i)}},d=function(e){return e&&"PRIV"===e.key&&"com.apple.streaming.transportStreamTimestamp"===e.info},h=function(e){var t=String.fromCharCode(e[0],e[1],e[2],e[3]),r=o(e,4);return{type:t,size:r,data:e.subarray(10,10+r)}},f=function(e){for(var t=0,r=[];n(e,t);){for(var i=o(e,t+6),s=(t+=10)+i;t+8<s;){var l=h(e.subarray(t)),u=c(l);u&&r.push(u),t+=l.size+10}a(e,t)&&(t+=10)}return r},c=function(e){return"PRIV"===e.type?v(e):"W"===e.type[0]?p(e):g(e)},v=function(e){if(!(e.size<2)){var t=y(e.data,!0),r=new Uint8Array(e.data.subarray(t.length+1));return{key:e.type,info:t,data:r.buffer}}},g=function(e){if(!(e.size<2)){if("TXXX"===e.type){var t=1,r=y(e.data.subarray(t),!0);t+=r.length+1;var i=y(e.data.subarray(t));return{key:e.type,info:r,data:i}}var n=y(e.data.subarray(1));return{key:e.type,data:n}}},p=function(e){if("WXXX"===e.type){if(e.size<2)return;var t=1,r=y(e.data.subarray(t),!0);t+=r.length+1;var i=y(e.data.subarray(t));return{key:e.type,info:r,data:i}}var n=y(e.data);return{key:e.type,data:n}},m=function(e){if(8===e.data.byteLength){var t=new Uint8Array(e.data),r=1&t[3],i=(t[4]<<23)+(t[5]<<15)+(t[6]<<7)+t[7];return i/=45,r&&(i+=47721858.84),Math.round(i)}},y=function(e,t){void 0===t&&(t=!1);var r=T();if(r){var i=r.decode(e);if(t){var n=i.indexOf("\0");return-1!==n?i.substring(0,n):i}return i.replace(/\0/g,"")}for(var a,s,o,l=e.length,u="",d=0;d<l;){if(0===(a=e[d++])&&t)return u;if(0!==a&&3!==a)switch(a>>4){case 0:case 1:case 2:case 3:case 4:case 5:case 6:case 7:u+=String.fromCharCode(a);break;case 12:case 13:s=e[d++],u+=String.fromCharCode((31&a)<<6|63&s);break;case 14:s=e[d++],o=e[d++],u+=String.fromCharCode((15&a)<<12|(63&s)<<6|(63&o)<<0)}}return u},E={decodeTextFrame:g};function T(){return i||void 0===self.TextDecoder||(i=new self.TextDecoder("utf-8")),i}},182:(e,t,r)=>{"use strict";r.r(t),r.d(t,{default:()=>o});var i=r(764),n=r(851),a=r(93),s=r(729);function o(e){var t=new s.EventEmitter,r=function(t,r){e.postMessage({event:t,data:r})};t.on(n.Events.FRAG_DECRYPTED,r),t.on(n.Events.ERROR,r),e.addEventListener("message",(function(n){var s=n.data;switch(s.cmd){case"init":var o=JSON.parse(s.config);e.transmuxer=new i.default(t,s.typeSupported,o,s.vendor,s.id),(0,a.enableLogs)(o.debug,s.id),function(){var e=function(e){a.logger[e]=function(t){r("workerLog",{logType:e,message:t})}};for(var t in a.logger)e(t)}(),r("init",null);break;case"configure":e.transmuxer.configure(s.config);break;case"demux":var u=e.transmuxer.push(s.data,s.decryptdata,s.chunkMeta,s.state);(0,i.isPromise)(u)?u.then((function(t){l(e,t)})):l(e,u);break;case"flush":var h=s.chunkMeta,f=e.transmuxer.flush(h);(0,i.isPromise)(f)?f.then((function(t){d(e,t,h)})):d(e,f,h)}}))}function l(e,t){if(!((r=t.remuxResult).audio||r.video||r.text||r.id3||r.initSegment))return!1;var r,i=[],n=t.remuxResult,a=n.audio,s=n.video;return a&&u(i,a),s&&u(i,s),e.postMessage({event:"transmuxComplete",data:t},i),!0}function u(e,t){t.data1&&e.push(t.data1.buffer),t.data2&&e.push(t.data2.buffer)}function d(e,t,r){t.reduce((function(t,r){return l(e,r)||t}),!1)||e.postMessage({event:"transmuxComplete",data:t[0]}),e.postMessage({event:"flush",data:r})}},764:(e,t,r)=>{"use strict";r.r(t),r.d(t,{TransmuxConfig:()=>Ee,TransmuxState:()=>Te,default:()=>pe,isPromise:()=>ye});var i=r(851),n=r(973),a=r(21),s=r(965),o=r(181),l=r(856);function u(e,t){return void 0===e&&(e=""),void 0===t&&(t=9e4),{type:e,id:-1,pid:-1,inputTimeScale:t,sequenceNumber:-1,samples:[],dropped:0}}var d=r(63),h=r(145),f=function(){function e(){this._audioTrack=void 0,this._id3Track=void 0,this.frameIndex=0,this.cachedData=null,this.basePTS=null,this.initPTS=null,this.lastPTS=null}var t=e.prototype;return t.resetInitSegment=function(e,t,r,i){this._id3Track={type:"id3",id:3,pid:-1,inputTimeScale:9e4,sequenceNumber:0,samples:[],dropped:0}},t.resetTimeStamp=function(e){this.initPTS=e,this.resetContiguity()},t.resetContiguity=function(){this.basePTS=null,this.lastPTS=null,this.frameIndex=0},t.canParse=function(e,t){return!1},t.appendFrame=function(e,t,r){},t.demux=function(e,t){this.cachedData&&(e=(0,d.appendUint8Array)(this.cachedData,e),this.cachedData=null);var r,i=o.getID3Data(e,0),n=i?i.length:0,a=this._audioTrack,f=this._id3Track,v=i?o.getTimeStamp(i):void 0,g=e.length;for((null===this.basePTS||0===this.frameIndex&&(0,s.isFiniteNumber)(v))&&(this.basePTS=c(v,t,this.initPTS),this.lastPTS=this.basePTS),null===this.lastPTS&&(this.lastPTS=this.basePTS),i&&i.length>0&&f.samples.push({pts:this.lastPTS,dts:this.lastPTS,data:i,type:l.MetadataSchema.audioId3,duration:Number.POSITIVE_INFINITY});n<g;){if(this.canParse(e,n)){var p=this.appendFrame(a,e,n);p?(this.frameIndex++,this.lastPTS=p.sample.pts,r=n+=p.length):n=g}else o.canParse(e,n)?(i=o.getID3Data(e,n),f.samples.push({pts:this.lastPTS,dts:this.lastPTS,data:i,type:l.MetadataSchema.audioId3,duration:Number.POSITIVE_INFINITY}),r=n+=i.length):n++;if(n===g&&r!==g){var m=(0,h.sliceUint8)(e,r);this.cachedData?this.cachedData=(0,d.appendUint8Array)(this.cachedData,m):this.cachedData=m}}return{audioTrack:a,videoTrack:u(),id3Track:f,textTrack:u()}},t.demuxSampleAes=function(e,t,r){return Promise.reject(new Error("["+this+"] This demuxer does not support Sample-AES decryption"))},t.flush=function(e){var t=this.cachedData;return t&&(this.cachedData=null,this.demux(t,0)),{audioTrack:this._audioTrack,videoTrack:u(),id3Track:this._id3Track,textTrack:u()}},t.destroy=function(){},e}(),c=function(e,t,r){return(0,s.isFiniteNumber)(e)?90*e:9e4*t+(r||0)};const v=f;var g=r(93);function p(e,t){return 255===e[t]&&240==(246&e[t+1])}function m(e,t){return 1&e[t+1]?7:9}function y(e,t){return(3&e[t+3])<<11|e[t+4]<<3|(224&e[t+5])>>>5}function E(e,t){return t+1<e.length&&p(e,t)}function T(e,t){if(E(e,t)){var r=m(e,t);if(t+r>=e.length)return!1;var i=y(e,t);if(i<=r)return!1;var n=t+i;return n===e.length||E(e,n)}return!1}function S(e,t,r,a,s){if(!e.samplerate){var o=function(e,t,r,a){var s,o,l,u,d=navigator.userAgent.toLowerCase(),h=a,f=[96e3,88200,64e3,48e3,44100,32e3,24e3,22050,16e3,12e3,11025,8e3,7350];s=1+((192&t[r+2])>>>6);var c=(60&t[r+2])>>>2;if(!(c>f.length-1))return l=(1&t[r+2])<<2,l|=(192&t[r+3])>>>6,g.logger.log("manifest codec:"+a+", ADTS type:"+s+", samplingIndex:"+c),/firefox/i.test(d)?c>=6?(s=5,u=new Array(4),o=c-3):(s=2,u=new Array(2),o=c):-1!==d.indexOf("android")?(s=2,u=new Array(2),o=c):(s=5,u=new Array(4),a&&(-1!==a.indexOf("mp4a.40.29")||-1!==a.indexOf("mp4a.40.5"))||!a&&c>=6?o=c-3:((a&&-1!==a.indexOf("mp4a.40.2")&&(c>=6&&1===l||/vivaldi/i.test(d))||!a&&1===l)&&(s=2,u=new Array(2)),o=c)),u[0]=s<<3,u[0]|=(14&c)>>1,u[1]|=(1&c)<<7,u[1]|=l<<3,5===s&&(u[1]|=(14&o)>>1,u[2]=(1&o)<<7,u[2]|=8,u[3]=0),{config:u,samplerate:f[c],channelCount:l,codec:"mp4a.40."+s,manifestCodec:h};e.trigger(i.Events.ERROR,{type:n.ErrorTypes.MEDIA_ERROR,details:n.ErrorDetails.FRAG_PARSING_ERROR,fatal:!0,reason:"invalid ADTS sampling index:"+c})}(t,r,a,s);if(!o)return;e.config=o.config,e.samplerate=o.samplerate,e.channelCount=o.channelCount,e.codec=o.codec,e.manifestCodec=o.manifestCodec,g.logger.log("parsed codec:"+e.codec+", rate:"+o.samplerate+", channels:"+o.channelCount)}}function b(e){return 9216e4/e}function L(e,t,r,i,n){var a,s=i+n*b(e.samplerate),o=function(e,t){var r=m(e,t);if(t+r<=e.length){var i=y(e,t)-r;if(i>0)return{headerLength:r,frameLength:i}}}(t,r);if(o){var l=o.frameLength,u=o.headerLength,d=u+l,h=Math.max(0,r+d-t.length);h?(a=new Uint8Array(d-u)).set(t.subarray(r+u,t.length),0):a=t.subarray(r+u,r+d);var f={unit:a,pts:s};return h||e.samples.push(f),{sample:f,length:d,missing:h}}var c=t.length-r;return(a=new Uint8Array(c)).set(t.subarray(r,t.length),0),{sample:{unit:a,pts:s},length:c,missing:-1}}function A(e,t){return A=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},A(e,t)}const D=function(e){var t,r;function i(t,r){var i;return(i=e.call(this)||this).observer=void 0,i.config=void 0,i.observer=t,i.config=r,i}r=e,(t=i).prototype=Object.create(r.prototype),t.prototype.constructor=t,A(t,r);var n=i.prototype;return n.resetInitSegment=function(t,r,i,n){e.prototype.resetInitSegment.call(this,t,r,i,n),this._audioTrack={container:"audio/adts",type:"audio",id:2,pid:-1,sequenceNumber:0,segmentCodec:"aac",samples:[],manifestCodec:r,duration:n,inputTimeScale:9e4,dropped:0}},i.probe=function(e){if(!e)return!1;for(var t=(o.getID3Data(e,0)||[]).length,r=e.length;t<r;t++)if(T(e,t))return g.logger.log("ADTS sync word found !"),!0;return!1},n.canParse=function(e,t){return function(e,t){return function(e,t){return t+5<e.length}(e,t)&&p(e,t)&&y(e,t)<=e.length-t}(e,t)},n.appendFrame=function(e,t,r){S(e,this.observer,t,r,e.manifestCodec);var i=L(e,t,r,this.basePTS,this.frameIndex);if(i&&0===i.missing)return i},i}(v);var R=/\/emsg[-/]ID3/i;const k=function(){function e(e,t){this.remainderData=null,this.timeOffset=0,this.config=void 0,this.videoTrack=void 0,this.audioTrack=void 0,this.id3Track=void 0,this.txtTrack=void 0,this.config=t}var t=e.prototype;return t.resetTimeStamp=function(){},t.resetInitSegment=function(e,t,r,i){var n=(0,d.parseInitSegment)(e),a=this.videoTrack=u("video",1),s=this.audioTrack=u("audio",1),o=this.txtTrack=u("text",1);if(this.id3Track=u("id3",1),this.timeOffset=0,n.video){var l=n.video,h=l.id,f=l.timescale,c=l.codec;a.id=h,a.timescale=o.timescale=f,a.codec=c}if(n.audio){var v=n.audio,g=v.id,p=v.timescale,m=v.codec;s.id=g,s.timescale=p,s.codec=m}o.id=d.RemuxerTrackIdConfig.text,a.sampleDuration=0,a.duration=s.duration=i},t.resetContiguity=function(){},e.probe=function(e){return e=e.length>16384?e.subarray(0,16384):e,(0,d.findBox)(e,["moof"]).length>0},t.demux=function(e,t){this.timeOffset=t;var r=e,i=this.videoTrack,n=this.txtTrack;if(this.config.progressive){this.remainderData&&(r=(0,d.appendUint8Array)(this.remainderData,e));var a=(0,d.segmentValidRange)(r);this.remainderData=a.remainder,i.samples=a.valid||new Uint8Array}else i.samples=r;var s=this.extractID3Track(i,t);return n.samples=(0,d.parseSamples)(t,i),{videoTrack:i,audioTrack:this.audioTrack,id3Track:s,textTrack:this.txtTrack}},t.flush=function(){var e=this.timeOffset,t=this.videoTrack,r=this.txtTrack;t.samples=this.remainderData||new Uint8Array,this.remainderData=null;var i=this.extractID3Track(t,this.timeOffset);return r.samples=(0,d.parseSamples)(e,t),{videoTrack:t,audioTrack:u(),id3Track:i,textTrack:u()}},t.extractID3Track=function(e,t){var r=this.id3Track;if(e.samples.length){var i=(0,d.findBox)(e.samples,["emsg"]);i&&i.forEach((function(e){var i=(0,d.parseEmsg)(e);if(R.test(i.schemeIdUri)){var n=(0,s.isFiniteNumber)(i.presentationTime)?i.presentationTime/i.timeScale:t+i.presentationTimeDelta/i.timeScale,a=4294967295===i.eventDuration?Number.POSITIVE_INFINITY:i.eventDuration/i.timeScale;a<=.001&&(a=Number.POSITIVE_INFINITY);var o=i.payload;r.samples.push({data:o,len:o.byteLength,dts:n,pts:n,type:l.MetadataSchema.emsg,duration:a})}}))}return r},t.demuxSampleAes=function(e,t,r){return Promise.reject(new Error("The MP4 demuxer does not support SAMPLE-AES decryption"))},t.destroy=function(){},e}();var _=null,x=[32,64,96,128,160,192,224,256,288,320,352,384,416,448,32,48,56,64,80,96,112,128,160,192,224,256,320,384,32,40,48,56,64,80,96,112,128,160,192,224,256,320,32,48,56,64,80,96,112,128,144,160,176,192,224,256,8,16,24,32,40,48,56,64,80,96,112,128,144,160],I=[44100,48e3,32e3,22050,24e3,16e3,11025,12e3,8e3],w=[[0,72,144,12],[0,0,0,0],[0,72,144,12],[0,144,144,12]],C=[0,1,1,4];function P(e,t,r,i,n){if(!(r+24>t.length)){var a=O(t,r);if(a&&r+a.frameLength<=t.length){var s=i+n*(9e4*a.samplesPerFrame/a.sampleRate),o={unit:t.subarray(r,r+a.frameLength),pts:s,dts:s};return e.config=[],e.channelCount=a.channelCount,e.samplerate=a.sampleRate,e.samples.push(o),{sample:o,length:a.frameLength,missing:0}}}}function O(e,t){var r=e[t+1]>>3&3,i=e[t+1]>>1&3,n=e[t+2]>>4&15,a=e[t+2]>>2&3;if(1!==r&&0!==n&&15!==n&&3!==a){var s=e[t+2]>>1&1,o=e[t+3]>>6,l=1e3*x[14*(3===r?3-i:3===i?3:4)+n-1],u=I[3*(3===r?0:2===r?1:2)+a],d=3===o?1:2,h=w[r][i],f=C[i],c=8*h*f,v=Math.floor(h*l/u+s)*f;if(null===_){var g=(navigator.userAgent||"").match(/Chrome\/(\d+)/i);_=g?parseInt(g[1]):0}return!!_&&_<=87&&2===i&&l>=224e3&&0===o&&(e[t+3]=128|e[t+3]),{sampleRate:u,channelCount:d,frameLength:v,samplesPerFrame:c}}}function F(e,t){return 255===e[t]&&224==(224&e[t+1])&&0!=(6&e[t+1])}function M(e,t){return t+1<e.length&&F(e,t)}function N(e,t){if(t+1<e.length&&F(e,t)){var r=O(e,t),i=4;null!=r&&r.frameLength&&(i=r.frameLength);var n=t+i;return n===e.length||M(e,n)}return!1}const U=function(){function e(e){this.data=void 0,this.bytesAvailable=void 0,this.word=void 0,this.bitsAvailable=void 0,this.data=e,this.bytesAvailable=e.byteLength,this.word=0,this.bitsAvailable=0}var t=e.prototype;return t.loadWord=function(){var e=this.data,t=this.bytesAvailable,r=e.byteLength-t,i=new Uint8Array(4),n=Math.min(4,t);if(0===n)throw new Error("no bytes available");i.set(e.subarray(r,r+n)),this.word=new DataView(i.buffer).getUint32(0),this.bitsAvailable=8*n,this.bytesAvailable-=n},t.skipBits=function(e){var t;this.bitsAvailable>e?(this.word<<=e,this.bitsAvailable-=e):(e-=this.bitsAvailable,e-=(t=e>>3)>>3,this.bytesAvailable-=t,this.loadWord(),this.word<<=e,this.bitsAvailable-=e)},t.readBits=function(e){var t=Math.min(this.bitsAvailable,e),r=this.word>>>32-t;return e>32&&g.logger.error("Cannot read more than 32 bits at a time"),this.bitsAvailable-=t,this.bitsAvailable>0?this.word<<=t:this.bytesAvailable>0&&this.loadWord(),(t=e-t)>0&&this.bitsAvailable?r<<t|this.readBits(t):r},t.skipLZ=function(){var e;for(e=0;e<this.bitsAvailable;++e)if(0!=(this.word&2147483648>>>e))return this.word<<=e,this.bitsAvailable-=e,e;return this.loadWord(),e+this.skipLZ()},t.skipUEG=function(){this.skipBits(1+this.skipLZ())},t.skipEG=function(){this.skipBits(1+this.skipLZ())},t.readUEG=function(){var e=this.skipLZ();return this.readBits(e+1)-1},t.readEG=function(){var e=this.readUEG();return 1&e?1+e>>>1:-1*(e>>>1)},t.readBoolean=function(){return 1===this.readBits(1)},t.readUByte=function(){return this.readBits(8)},t.readUShort=function(){return this.readBits(16)},t.readUInt=function(){return this.readBits(32)},t.skipScalingList=function(e){for(var t=8,r=8,i=0;i<e;i++)0!==r&&(r=(t+this.readEG()+256)%256),t=0===r?t:r},t.readSPS=function(){var e,t,r,i=0,n=0,a=0,s=0,o=this.readUByte.bind(this),l=this.readBits.bind(this),u=this.readUEG.bind(this),d=this.readBoolean.bind(this),h=this.skipBits.bind(this),f=this.skipEG.bind(this),c=this.skipUEG.bind(this),v=this.skipScalingList.bind(this);o();var g=o();if(l(5),h(3),o(),c(),100===g||110===g||122===g||244===g||44===g||83===g||86===g||118===g||128===g){var p=u();if(3===p&&h(1),c(),c(),h(1),d())for(t=3!==p?8:12,r=0;r<t;r++)d()&&v(r<6?16:64)}c();var m=u();if(0===m)u();else if(1===m)for(h(1),f(),f(),e=u(),r=0;r<e;r++)f();c(),h(1);var y=u(),E=u(),T=l(1);0===T&&h(1),h(1),d()&&(i=u(),n=u(),a=u(),s=u());var S=[1,1];if(d()&&d())switch(o()){case 1:S=[1,1];break;case 2:S=[12,11];break;case 3:S=[10,11];break;case 4:S=[16,11];break;case 5:S=[40,33];break;case 6:S=[24,11];break;case 7:S=[20,11];break;case 8:S=[32,11];break;case 9:S=[80,33];break;case 10:S=[18,11];break;case 11:S=[15,11];break;case 12:S=[64,33];break;case 13:S=[160,99];break;case 14:S=[4,3];break;case 15:S=[3,2];break;case 16:S=[2,1];break;case 255:S=[o()<<8|o(),o()<<8|o()]}return{width:Math.ceil(16*(y+1)-2*i-2*n),height:(2-T)*(E+1)*16-(T?2:4)*(a+s),pixelRatio:S}},t.readSliceType=function(){return this.readUByte(),this.readUEG(),this.readUEG()},e}(),B=function(){function e(e,t,r){this.keyData=void 0,this.decrypter=void 0,this.keyData=r,this.decrypter=new a.default(e,t,{removePKCS7Padding:!1})}var t=e.prototype;return t.decryptBuffer=function(e,t){this.decrypter.decrypt(e,this.keyData.key.buffer,this.keyData.iv.buffer,t)},t.decryptAacSample=function(e,t,r,i){var n=e[t].unit;if(!(n.length<=16)){var a=n.subarray(16,n.length-n.length%16),s=a.buffer.slice(a.byteOffset,a.byteOffset+a.length),o=this;this.decryptBuffer(s,(function(a){var s=new Uint8Array(a);n.set(s,16),i||o.decryptAacSamples(e,t+1,r)}))}},t.decryptAacSamples=function(e,t,r){for(;;t++){if(t>=e.length)return void r();if(!(e[t].unit.length<32)){var i=this.decrypter.isSync();if(this.decryptAacSample(e,t,r,i),!i)return}}},t.getAvcEncryptedData=function(e){for(var t=16*Math.floor((e.length-48)/160)+16,r=new Int8Array(t),i=0,n=32;n<e.length-16;n+=160,i+=16)r.set(e.subarray(n,n+16),i);return r},t.getAvcDecryptedUnit=function(e,t){for(var r=new Uint8Array(t),i=0,n=32;n<e.length-16;n+=160,i+=16)e.set(r.subarray(i,i+16),n);return e},t.decryptAvcSample=function(e,t,r,i,n,a){var s=(0,d.discardEPB)(n.data),o=this.getAvcEncryptedData(s),l=this;this.decryptBuffer(o.buffer,(function(o){n.data=l.getAvcDecryptedUnit(s,o),a||l.decryptAvcSamples(e,t,r+1,i)}))},t.decryptAvcSamples=function(e,t,r,i){if(e instanceof Uint8Array)throw new Error("Cannot decrypt samples of type Uint8Array");for(;;t++,r=0){if(t>=e.length)return void i();for(var n=e[t].units;!(r>=n.length);r++){var a=n[r];if(!(a.data.length<=48||1!==a.type&&5!==a.type)){var s=this.decrypter.isSync();if(this.decryptAvcSample(e,t,r,i,a,s),!s)return}}}},e}();function G(){return G=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},G.apply(this,arguments)}var V=188;function H(e,t,r,i){return{key:e,frame:!1,pts:t,dts:r,units:[],debug:i,length:0}}function j(e,t){return(31&e[t+10])<<8|e[t+11]}function K(e,t,r,i){var n={audio:-1,avc:-1,id3:-1,segmentCodec:"aac"},a=t+3+((15&e[t+1])<<8|e[t+2])-4;for(t+=12+((15&e[t+10])<<8|e[t+11]);t<a;){var s=(31&e[t+1])<<8|e[t+2];switch(e[t]){case 207:if(!i){g.logger.log("ADTS AAC with AES-128-CBC frame encryption found in unencrypted stream");break}case 15:-1===n.audio&&(n.audio=s);break;case 21:-1===n.id3&&(n.id3=s);break;case 219:if(!i){g.logger.log("H.264 with AES-128-CBC slice encryption found in unencrypted stream");break}case 27:-1===n.avc&&(n.avc=s);break;case 3:case 4:!0!==r.mpeg&&!0!==r.mp3?g.logger.log("MPEG audio found, not supported in this browser"):-1===n.audio&&(n.audio=s,n.segmentCodec="mp3");break;case 36:g.logger.warn("Unsupported HEVC stream type found")}t+=5+((15&e[t+3])<<8|e[t+4])}return n}function W(e){var t,r,i,n,a,s=0,o=e.data;if(!e||0===e.size)return null;for(;o[0].length<19&&o.length>1;){var l=new Uint8Array(o[0].length+o[1].length);l.set(o[0]),l.set(o[1],o[0].length),o[0]=l,o.splice(1,1)}if(1===((t=o[0])[0]<<16)+(t[1]<<8)+t[2]){if((r=(t[4]<<8)+t[5])&&r>e.size-6)return null;var u=t[7];192&u&&(n=536870912*(14&t[9])+4194304*(255&t[10])+16384*(254&t[11])+128*(255&t[12])+(254&t[13])/2,64&u?n-(a=536870912*(14&t[14])+4194304*(255&t[15])+16384*(254&t[16])+128*(255&t[17])+(254&t[18])/2)>54e5&&(g.logger.warn(Math.round((n-a)/9e4)+"s delta between PTS and DTS, align them"),n=a):a=n);var d=(i=t[8])+9;if(e.size<=d)return null;e.size-=d;for(var h=new Uint8Array(e.size),f=0,c=o.length;f<c;f++){var v=(t=o[f]).byteLength;if(d){if(d>v){d-=v;continue}t=t.subarray(d),v-=d,d=0}h.set(t,s),s+=v}return r&&(r-=i+3),{data:h,pts:n,dts:a,len:r}}return null}function Y(e,t){if(e.units.length&&e.frame){if(void 0===e.pts){var r=t.samples,i=r.length;if(!i)return void t.dropped++;var n=r[i-1];e.pts=n.pts,e.dts=n.dts}t.samples.push(e)}e.debug.length&&g.logger.log(e.pts+"/"+e.dts+":"+e.debug)}const q=function(){function e(e,t,r){this.observer=void 0,this.config=void 0,this.typeSupported=void 0,this.sampleAes=null,this.pmtParsed=!1,this.audioCodec=void 0,this.videoCodec=void 0,this._duration=0,this._pmtId=-1,this._avcTrack=void 0,this._audioTrack=void 0,this._id3Track=void 0,this._txtTrack=void 0,this.aacOverFlow=null,this.avcSample=null,this.remainderData=null,this.observer=e,this.config=t,this.typeSupported=r}e.probe=function(t){var r=e.syncOffset(t);return r>0&&g.logger.warn("MPEG2-TS detected but first sync word found @ offset "+r),-1!==r},e.syncOffset=function(e){for(var t=Math.min(940,e.length-376)+1,r=0;r<t;){if(71===e[r]&&71===e[r+V])return r;r++}return-1},e.createTrack=function(e,t){return{container:"video"===e||"audio"===e?"video/mp2t":void 0,type:e,id:d.RemuxerTrackIdConfig[e],pid:-1,inputTimeScale:9e4,sequenceNumber:0,samples:[],dropped:0,duration:"audio"===e?t:void 0}};var t=e.prototype;return t.resetInitSegment=function(t,r,i,n){this.pmtParsed=!1,this._pmtId=-1,this._avcTrack=e.createTrack("video"),this._audioTrack=e.createTrack("audio",n),this._id3Track=e.createTrack("id3"),this._txtTrack=e.createTrack("text"),this._audioTrack.segmentCodec="aac",this.aacOverFlow=null,this.avcSample=null,this.remainderData=null,this.audioCodec=r,this.videoCodec=i,this._duration=n},t.resetTimeStamp=function(){},t.resetContiguity=function(){var e=this._audioTrack,t=this._avcTrack,r=this._id3Track;e&&(e.pesData=null),t&&(t.pesData=null),r&&(r.pesData=null),this.aacOverFlow=null,this.avcSample=null,this.remainderData=null},t.demux=function(t,r,a,s){var o;void 0===a&&(a=!1),void 0===s&&(s=!1),a||(this.sampleAes=null);var l=this._avcTrack,u=this._audioTrack,h=this._id3Track,f=this._txtTrack,c=l.pid,v=l.pesData,p=u.pid,m=h.pid,y=u.pesData,E=h.pesData,T=null,S=this.pmtParsed,b=this._pmtId,L=t.length;if(this.remainderData&&(L=(t=(0,d.appendUint8Array)(this.remainderData,t)).length,this.remainderData=null),L<V&&!s)return this.remainderData=t,{audioTrack:u,videoTrack:l,id3Track:h,textTrack:f};var A=Math.max(0,e.syncOffset(t));(L-=(L-A)%V)<t.byteLength&&!s&&(this.remainderData=new Uint8Array(t.buffer,L,t.buffer.byteLength-L));for(var D=0,R=A;R<L;R+=V)if(71===t[R]){var k=!!(64&t[R+1]),_=((31&t[R+1])<<8)+t[R+2],x=void 0;if((48&t[R+3])>>4>1){if((x=R+5+t[R+4])===R+V)continue}else x=R+4;switch(_){case c:k&&(v&&(o=W(v))&&this.parseAVCPES(l,f,o,!1),v={data:[],size:0}),v&&(v.data.push(t.subarray(x,R+V)),v.size+=R+V-x);break;case p:if(k){if(y&&(o=W(y)))switch(u.segmentCodec){case"aac":this.parseAACPES(u,o);break;case"mp3":this.parseMPEGPES(u,o)}y={data:[],size:0}}y&&(y.data.push(t.subarray(x,R+V)),y.size+=R+V-x);break;case m:k&&(E&&(o=W(E))&&this.parseID3PES(h,o),E={data:[],size:0}),E&&(E.data.push(t.subarray(x,R+V)),E.size+=R+V-x);break;case 0:k&&(x+=t[x]+1),b=this._pmtId=j(t,x);break;case b:k&&(x+=t[x]+1);var I=K(t,x,this.typeSupported,a);(c=I.avc)>0&&(l.pid=c),(p=I.audio)>0&&(u.pid=p,u.segmentCodec=I.segmentCodec),(m=I.id3)>0&&(h.pid=m),null===T||S||(g.logger.log("unknown PID '"+T+"' in TS found"),T=null,R=A-188),S=this.pmtParsed=!0;break;case 17:case 8191:break;default:T=_}}else D++;D>0&&this.observer.emit(i.Events.ERROR,i.Events.ERROR,{type:n.ErrorTypes.MEDIA_ERROR,details:n.ErrorDetails.FRAG_PARSING_ERROR,fatal:!1,reason:"Found "+D+" TS packet/s that do not start with 0x47"}),l.pesData=v,u.pesData=y,h.pesData=E;var w={audioTrack:u,videoTrack:l,id3Track:h,textTrack:f};return s&&this.extractRemainingSamples(w),w},t.flush=function(){var e,t=this.remainderData;return this.remainderData=null,e=t?this.demux(t,-1,!1,!0):{videoTrack:this._avcTrack,audioTrack:this._audioTrack,id3Track:this._id3Track,textTrack:this._txtTrack},this.extractRemainingSamples(e),this.sampleAes?this.decrypt(e,this.sampleAes):e},t.extractRemainingSamples=function(e){var t,r=e.audioTrack,i=e.videoTrack,n=e.id3Track,a=e.textTrack,s=i.pesData,o=r.pesData,l=n.pesData;if(s&&(t=W(s))?(this.parseAVCPES(i,a,t,!0),i.pesData=null):i.pesData=s,o&&(t=W(o))){switch(r.segmentCodec){case"aac":this.parseAACPES(r,t);break;case"mp3":this.parseMPEGPES(r,t)}r.pesData=null}else null!=o&&o.size&&g.logger.log("last AAC PES packet truncated,might overlap between fragments"),r.pesData=o;l&&(t=W(l))?(this.parseID3PES(n,t),n.pesData=null):n.pesData=l},t.demuxSampleAes=function(e,t,r){var i=this.demux(e,r,!0,!this.config.progressive),n=this.sampleAes=new B(this.observer,this.config,t);return this.decrypt(i,n)},t.decrypt=function(e,t){return new Promise((function(r){var i=e.audioTrack,n=e.videoTrack;i.samples&&"aac"===i.segmentCodec?t.decryptAacSamples(i.samples,0,(function(){n.samples?t.decryptAvcSamples(n.samples,0,0,(function(){r(e)})):r(e)})):n.samples&&t.decryptAvcSamples(n.samples,0,0,(function(){r(e)}))}))},t.destroy=function(){this._duration=0},t.parseAVCPES=function(e,t,r,i){var n,a=this,s=this.parseAVCNALu(e,r.data),o=this.avcSample,l=!1;r.data=null,o&&s.length&&!e.audFound&&(Y(o,e),o=this.avcSample=H(!1,r.pts,r.dts,"")),s.forEach((function(i){switch(i.type){case 1:n=!0,o||(o=a.avcSample=H(!0,r.pts,r.dts,"")),o.frame=!0;var s=i.data;if(l&&s.length>4){var u=new U(s).readSliceType();2!==u&&4!==u&&7!==u&&9!==u||(o.key=!0)}break;case 5:n=!0,o||(o=a.avcSample=H(!0,r.pts,r.dts,"")),o.key=!0,o.frame=!0;break;case 6:n=!0,(0,d.parseSEIMessageFromNALu)(i.data,1,r.pts,t.samples);break;case 7:if(n=!0,l=!0,!e.sps){var h=new U(i.data).readSPS();e.width=h.width,e.height=h.height,e.pixelRatio=h.pixelRatio,e.sps=[i.data],e.duration=a._duration;for(var f=i.data.subarray(1,4),c="avc1.",v=0;v<3;v++){var g=f[v].toString(16);g.length<2&&(g="0"+g),c+=g}e.codec=c}break;case 8:n=!0,e.pps||(e.pps=[i.data]);break;case 9:n=!1,e.audFound=!0,o&&Y(o,e),o=a.avcSample=H(!1,r.pts,r.dts,"");break;case 12:n=!0;break;default:n=!1,o&&(o.debug+="unknown NAL "+i.type+" ")}o&&n&&o.units.push(i)})),i&&o&&(Y(o,e),this.avcSample=null)},t.getLastNalUnit=function(e){var t,r,i=this.avcSample;if(i&&0!==i.units.length||(i=e[e.length-1]),null!==(t=i)&&void 0!==t&&t.units){var n=i.units;r=n[n.length-1]}return r},t.parseAVCNALu=function(e,t){var r,i,n=t.byteLength,a=e.naluState||0,s=a,o=[],l=0,u=-1,d=0;for(-1===a&&(u=0,d=31&t[0],a=0,l=1);l<n;)if(r=t[l++],a)if(1!==a)if(r)if(1===r){if(u>=0){var h={data:t.subarray(u,l-a-1),type:d};o.push(h)}else{var f=this.getLastNalUnit(e.samples);if(f&&(s&&l<=4-s&&f.state&&(f.data=f.data.subarray(0,f.data.byteLength-s)),(i=l-a-1)>0)){var c=new Uint8Array(f.data.byteLength+i);c.set(f.data,0),c.set(t.subarray(0,i),f.data.byteLength),f.data=c,f.state=0}}l<n?(u=l,d=31&t[l],a=0):a=-1}else a=0;else a=3;else a=r?0:2;else a=r?0:1;if(u>=0&&a>=0){var v={data:t.subarray(u,n),type:d,state:a};o.push(v)}if(0===o.length){var g=this.getLastNalUnit(e.samples);if(g){var p=new Uint8Array(g.data.byteLength+t.byteLength);p.set(g.data,0),p.set(t,g.data.byteLength),g.data=p}}return e.naluState=a,o},t.parseAACPES=function(e,t){var r,a,s,o,l,u=0,d=this.aacOverFlow,h=t.data;if(d){this.aacOverFlow=null;var f=d.missing,c=d.sample.unit.byteLength;if(-1===f){var v=new Uint8Array(c+h.byteLength);v.set(d.sample.unit,0),v.set(h,c),h=v}else{var p=c-f;d.sample.unit.set(h.subarray(0,f),p),e.samples.push(d.sample),u=d.missing}}for(r=u,a=h.length;r<a-1&&!E(h,r);r++);if(r===u||(r<a-1?(s="AAC PES did not start with ADTS header,offset:"+r,o=!1):(s="no ADTS header found in AAC PES",o=!0),g.logger.warn("parsing error:"+s),this.observer.emit(i.Events.ERROR,i.Events.ERROR,{type:n.ErrorTypes.MEDIA_ERROR,details:n.ErrorDetails.FRAG_PARSING_ERROR,fatal:o,reason:s}),!o)){if(S(e,this.observer,h,r,this.audioCodec),void 0!==t.pts)l=t.pts;else{if(!d)return void g.logger.warn("[tsdemuxer]: AAC PES unknown PTS");var m=b(e.samplerate);l=d.sample.pts+m}for(var y,T=0;r<a;){if(r+=(y=L(e,h,r,l,T)).length,y.missing){this.aacOverFlow=y;break}for(T++;r<a-1&&!E(h,r);r++);}}},t.parseMPEGPES=function(e,t){var r=t.data,i=r.length,n=0,a=0,s=t.pts;if(void 0!==s)for(;a<i;)if(M(r,a)){var o=P(e,r,a,s,n);if(!o)break;a+=o.length,n++}else a++;else g.logger.warn("[tsdemuxer]: MPEG PES unknown PTS")},t.parseID3PES=function(e,t){if(void 0!==t.pts){var r=G({},t,{type:this._avcTrack?l.MetadataSchema.emsg:l.MetadataSchema.audioId3,duration:Number.POSITIVE_INFINITY});e.samples.push(r)}else g.logger.warn("[tsdemuxer]: ID3 PES unknown PTS")},e}();function z(e,t){return z=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},z(e,t)}const X=function(e){var t,r;function i(){return e.apply(this,arguments)||this}r=e,(t=i).prototype=Object.create(r.prototype),t.prototype.constructor=t,z(t,r);var n=i.prototype;return n.resetInitSegment=function(t,r,i,n){e.prototype.resetInitSegment.call(this,t,r,i,n),this._audioTrack={container:"audio/mpeg",type:"audio",id:2,pid:-1,sequenceNumber:0,segmentCodec:"mp3",samples:[],manifestCodec:r,duration:n,inputTimeScale:9e4,dropped:0}},i.probe=function(e){if(!e)return!1;for(var t=(o.getID3Data(e,0)||[]).length,r=e.length;t<r;t++)if(N(e,t))return g.logger.log("MPEG Audio sync word found !"),!0;return!1},n.canParse=function(e,t){return function(e,t){return F(e,t)&&4<=e.length-t}(e,t)},n.appendFrame=function(e,t,r){if(null!==this.basePTS)return P(e,t,r,this.basePTS,this.frameIndex)},i}(v),Q=function(){function e(){}return e.getSilentFrame=function(e,t){if("mp4a.40.2"===e){if(1===t)return new Uint8Array([0,200,0,128,35,128]);if(2===t)return new Uint8Array([33,0,73,144,2,25,0,35,128]);if(3===t)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,142]);if(4===t)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,128,44,128,8,2,56]);if(5===t)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,130,48,4,153,0,33,144,2,56]);if(6===t)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,130,48,4,153,0,33,144,2,0,178,0,32,8,224])}else{if(1===t)return new Uint8Array([1,64,34,128,163,78,230,128,186,8,0,0,0,28,6,241,193,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94]);if(2===t)return new Uint8Array([1,64,34,128,163,94,230,128,186,8,0,0,0,0,149,0,6,241,161,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94]);if(3===t)return new Uint8Array([1,64,34,128,163,94,230,128,186,8,0,0,0,0,149,0,6,241,161,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94])}},e}();var $=Math.pow(2,32)-1,Z=function(){function e(){}return e.init=function(){var t;for(t in e.types={avc1:[],avcC:[],btrt:[],dinf:[],dref:[],esds:[],ftyp:[],hdlr:[],mdat:[],mdhd:[],mdia:[],mfhd:[],minf:[],moof:[],moov:[],mp4a:[],".mp3":[],mvex:[],mvhd:[],pasp:[],sdtp:[],stbl:[],stco:[],stsc:[],stsd:[],stsz:[],stts:[],tfdt:[],tfhd:[],traf:[],trak:[],trun:[],trex:[],tkhd:[],vmhd:[],smhd:[]},e.types)e.types.hasOwnProperty(t)&&(e.types[t]=[t.charCodeAt(0),t.charCodeAt(1),t.charCodeAt(2),t.charCodeAt(3)]);var r=new Uint8Array([0,0,0,0,0,0,0,0,118,105,100,101,0,0,0,0,0,0,0,0,0,0,0,0,86,105,100,101,111,72,97,110,100,108,101,114,0]),i=new Uint8Array([0,0,0,0,0,0,0,0,115,111,117,110,0,0,0,0,0,0,0,0,0,0,0,0,83,111,117,110,100,72,97,110,100,108,101,114,0]);e.HDLR_TYPES={video:r,audio:i};var n=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,12,117,114,108,32,0,0,0,1]),a=new Uint8Array([0,0,0,0,0,0,0,0]);e.STTS=e.STSC=e.STCO=a,e.STSZ=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0]),e.VMHD=new Uint8Array([0,0,0,1,0,0,0,0,0,0,0,0]),e.SMHD=new Uint8Array([0,0,0,0,0,0,0,0]),e.STSD=new Uint8Array([0,0,0,0,0,0,0,1]);var s=new Uint8Array([105,115,111,109]),o=new Uint8Array([97,118,99,49]),l=new Uint8Array([0,0,0,1]);e.FTYP=e.box(e.types.ftyp,s,l,s,o),e.DINF=e.box(e.types.dinf,e.box(e.types.dref,n))},e.box=function(e){for(var t=8,r=arguments.length,i=new Array(r>1?r-1:0),n=1;n<r;n++)i[n-1]=arguments[n];for(var a=i.length,s=a;a--;)t+=i[a].byteLength;var o=new Uint8Array(t);for(o[0]=t>>24&255,o[1]=t>>16&255,o[2]=t>>8&255,o[3]=255&t,o.set(e,4),a=0,t=8;a<s;a++)o.set(i[a],t),t+=i[a].byteLength;return o},e.hdlr=function(t){return e.box(e.types.hdlr,e.HDLR_TYPES[t])},e.mdat=function(t){return e.box(e.types.mdat,t)},e.mdhd=function(t,r){r*=t;var i=Math.floor(r/($+1)),n=Math.floor(r%($+1));return e.box(e.types.mdhd,new Uint8Array([1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,3,t>>24&255,t>>16&255,t>>8&255,255&t,i>>24,i>>16&255,i>>8&255,255&i,n>>24,n>>16&255,n>>8&255,255&n,85,196,0,0]))},e.mdia=function(t){return e.box(e.types.mdia,e.mdhd(t.timescale,t.duration),e.hdlr(t.type),e.minf(t))},e.mfhd=function(t){return e.box(e.types.mfhd,new Uint8Array([0,0,0,0,t>>24,t>>16&255,t>>8&255,255&t]))},e.minf=function(t){return"audio"===t.type?e.box(e.types.minf,e.box(e.types.smhd,e.SMHD),e.DINF,e.stbl(t)):e.box(e.types.minf,e.box(e.types.vmhd,e.VMHD),e.DINF,e.stbl(t))},e.moof=function(t,r,i){return e.box(e.types.moof,e.mfhd(t),e.traf(i,r))},e.moov=function(t){for(var r=t.length,i=[];r--;)i[r]=e.trak(t[r]);return e.box.apply(null,[e.types.moov,e.mvhd(t[0].timescale,t[0].duration)].concat(i).concat(e.mvex(t)))},e.mvex=function(t){for(var r=t.length,i=[];r--;)i[r]=e.trex(t[r]);return e.box.apply(null,[e.types.mvex].concat(i))},e.mvhd=function(t,r){r*=t;var i=Math.floor(r/($+1)),n=Math.floor(r%($+1)),a=new Uint8Array([1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,3,t>>24&255,t>>16&255,t>>8&255,255&t,i>>24,i>>16&255,i>>8&255,255&i,n>>24,n>>16&255,n>>8&255,255&n,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255]);return e.box(e.types.mvhd,a)},e.sdtp=function(t){var r,i,n=t.samples||[],a=new Uint8Array(4+n.length);for(r=0;r<n.length;r++)i=n[r].flags,a[r+4]=i.dependsOn<<4|i.isDependedOn<<2|i.hasRedundancy;return e.box(e.types.sdtp,a)},e.stbl=function(t){return e.box(e.types.stbl,e.stsd(t),e.box(e.types.stts,e.STTS),e.box(e.types.stsc,e.STSC),e.box(e.types.stsz,e.STSZ),e.box(e.types.stco,e.STCO))},e.avc1=function(t){var r,i,n,a=[],s=[];for(r=0;r<t.sps.length;r++)n=(i=t.sps[r]).byteLength,a.push(n>>>8&255),a.push(255&n),a=a.concat(Array.prototype.slice.call(i));for(r=0;r<t.pps.length;r++)n=(i=t.pps[r]).byteLength,s.push(n>>>8&255),s.push(255&n),s=s.concat(Array.prototype.slice.call(i));var o=e.box(e.types.avcC,new Uint8Array([1,a[3],a[4],a[5],255,224|t.sps.length].concat(a).concat([t.pps.length]).concat(s))),l=t.width,u=t.height,d=t.pixelRatio[0],h=t.pixelRatio[1];return e.box(e.types.avc1,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,l>>8&255,255&l,u>>8&255,255&u,0,72,0,0,0,72,0,0,0,0,0,0,0,1,18,100,97,105,108,121,109,111,116,105,111,110,47,104,108,115,46,106,115,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,17,17]),o,e.box(e.types.btrt,new Uint8Array([0,28,156,128,0,45,198,192,0,45,198,192])),e.box(e.types.pasp,new Uint8Array([d>>24,d>>16&255,d>>8&255,255&d,h>>24,h>>16&255,h>>8&255,255&h])))},e.esds=function(e){var t=e.config.length;return new Uint8Array([0,0,0,0,3,23+t,0,1,0,4,15+t,64,21,0,0,0,0,0,0,0,0,0,0,0,5].concat([t]).concat(e.config).concat([6,1,2]))},e.mp4a=function(t){var r=t.samplerate;return e.box(e.types.mp4a,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,t.channelCount,0,16,0,0,0,0,r>>8&255,255&r,0,0]),e.box(e.types.esds,e.esds(t)))},e.mp3=function(t){var r=t.samplerate;return e.box(e.types[".mp3"],new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,t.channelCount,0,16,0,0,0,0,r>>8&255,255&r,0,0]))},e.stsd=function(t){return"audio"===t.type?"mp3"===t.segmentCodec&&"mp3"===t.codec?e.box(e.types.stsd,e.STSD,e.mp3(t)):e.box(e.types.stsd,e.STSD,e.mp4a(t)):e.box(e.types.stsd,e.STSD,e.avc1(t))},e.tkhd=function(t){var r=t.id,i=t.duration*t.timescale,n=t.width,a=t.height,s=Math.floor(i/($+1)),o=Math.floor(i%($+1));return e.box(e.types.tkhd,new Uint8Array([1,0,0,7,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,3,r>>24&255,r>>16&255,r>>8&255,255&r,0,0,0,0,s>>24,s>>16&255,s>>8&255,255&s,o>>24,o>>16&255,o>>8&255,255&o,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,n>>8&255,255&n,0,0,a>>8&255,255&a,0,0]))},e.traf=function(t,r){var i=e.sdtp(t),n=t.id,a=Math.floor(r/($+1)),s=Math.floor(r%($+1));return e.box(e.types.traf,e.box(e.types.tfhd,new Uint8Array([0,0,0,0,n>>24,n>>16&255,n>>8&255,255&n])),e.box(e.types.tfdt,new Uint8Array([1,0,0,0,a>>24,a>>16&255,a>>8&255,255&a,s>>24,s>>16&255,s>>8&255,255&s])),e.trun(t,i.length+16+20+8+16+8+8),i)},e.trak=function(t){return t.duration=t.duration||4294967295,e.box(e.types.trak,e.tkhd(t),e.mdia(t))},e.trex=function(t){var r=t.id;return e.box(e.types.trex,new Uint8Array([0,0,0,0,r>>24,r>>16&255,r>>8&255,255&r,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,1]))},e.trun=function(t,r){var i,n,a,s,o,l,u=t.samples||[],d=u.length,h=12+16*d,f=new Uint8Array(h);for(r+=8+h,f.set(["video"===t.type?1:0,0,15,1,d>>>24&255,d>>>16&255,d>>>8&255,255&d,r>>>24&255,r>>>16&255,r>>>8&255,255&r],0),i=0;i<d;i++)a=(n=u[i]).duration,s=n.size,o=n.flags,l=n.cts,f.set([a>>>24&255,a>>>16&255,a>>>8&255,255&a,s>>>24&255,s>>>16&255,s>>>8&255,255&s,o.isLeading<<2|o.dependsOn,o.isDependedOn<<6|o.hasRedundancy<<4|o.paddingValue<<1|o.isNonSync,61440&o.degradPrio,15&o.degradPrio,l>>>24&255,l>>>16&255,l>>>8&255,255&l],12+16*i);return e.box(e.types.trun,f)},e.initSegment=function(t){e.types||e.init();var r=e.moov(t),i=new Uint8Array(e.FTYP.byteLength+r.byteLength);return i.set(e.FTYP),i.set(r,e.FTYP.byteLength),i},e}();Z.types=void 0,Z.HDLR_TYPES=void 0,Z.STTS=void 0,Z.STSC=void 0,Z.STCO=void 0,Z.STSZ=void 0,Z.VMHD=void 0,Z.SMHD=void 0,Z.STSD=void 0,Z.FTYP=void 0,Z.DINF=void 0;const J=Z;var ee=r(308);function te(e,t){return void 0===t&&(t=!1),function(e,t,r,i){void 0===r&&(r=1),void 0===i&&(i=!1);var n=e*t*r;return i?Math.round(n):n}(e,1e3,1/9e4,t)}function re(){return re=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},re.apply(this,arguments)}var ie=null,ne=null,ae=function(){function e(e,t,r,i){if(void 0===i&&(i=""),this.observer=void 0,this.config=void 0,this.typeSupported=void 0,this.ISGenerated=!1,this._initPTS=void 0,this._initDTS=void 0,this.nextAvcDts=null,this.nextAudioPts=null,this.videoSampleDuration=null,this.isAudioContiguous=!1,this.isVideoContiguous=!1,this.observer=e,this.config=t,this.typeSupported=r,this.ISGenerated=!1,null===ie){var n=(navigator.userAgent||"").match(/Chrome\/(\d+)/i);ie=n?parseInt(n[1]):0}if(null===ne){var a=navigator.userAgent.match(/Safari\/(\d+)/i);ne=a?parseInt(a[1]):0}}var t=e.prototype;return t.destroy=function(){},t.resetTimeStamp=function(e){g.logger.log("[mp4-remuxer]: initPTS & initDTS reset"),this._initPTS=this._initDTS=e},t.resetNextTimestamp=function(){g.logger.log("[mp4-remuxer]: reset next timestamp"),this.isVideoContiguous=!1,this.isAudioContiguous=!1},t.resetInitSegment=function(){g.logger.log("[mp4-remuxer]: ISGenerated flag reset"),this.ISGenerated=!1},t.getVideoStartPts=function(e){var t=!1,r=e.reduce((function(e,r){var i=r.pts-e;return i<-4294967296?(t=!0,se(e,r.pts)):i>0?e:r.pts}),e[0].pts);return t&&g.logger.debug("PTS rollover detected"),r},t.remux=function(e,t,r,i,n,a,s,o){var l,u,d,h,f,c,v=n,p=n,m=e.pid>-1,y=t.pid>-1,E=t.samples.length,T=e.samples.length>0,S=s&&E>0||E>1;if((!m||T)&&(!y||S)||this.ISGenerated||s){this.ISGenerated||(d=this.generateIS(e,t,n));var b,L=this.isVideoContiguous,A=-1;if(S&&(A=function(e){for(var t=0;t<e.length;t++)if(e[t].key)return t;return-1}(t.samples),!L&&this.config.forceKeyFrameOnDiscontinuity))if(c=!0,A>0){g.logger.warn("[mp4-remuxer]: Dropped "+A+" out of "+E+" video samples due to a missing keyframe");var D=this.getVideoStartPts(t.samples);t.samples=t.samples.slice(A),t.dropped+=A,b=p+=(t.samples[0].pts-D)/t.inputTimeScale}else-1===A&&(g.logger.warn("[mp4-remuxer]: No keyframe found out of "+E+" video samples"),c=!1);if(this.ISGenerated){if(T&&S){var R=this.getVideoStartPts(t.samples),k=(se(e.samples[0].pts,R)-R)/t.inputTimeScale;v+=Math.max(0,k),p+=Math.max(0,-k)}if(T){if(e.samplerate||(g.logger.warn("[mp4-remuxer]: regenerate InitSegment as audio detected"),d=this.generateIS(e,t,n)),u=this.remuxAudio(e,v,this.isAudioContiguous,a,y||S||o===ee.PlaylistLevelType.AUDIO?p:void 0),S){var _=u?u.endPTS-u.startPTS:0;t.inputTimeScale||(g.logger.warn("[mp4-remuxer]: regenerate InitSegment as video detected"),d=this.generateIS(e,t,n)),l=this.remuxVideo(t,p,L,_)}}else S&&(l=this.remuxVideo(t,p,L,0));l&&(l.firstKeyFrame=A,l.independent=-1!==A,l.firstKeyFramePTS=b)}}return this.ISGenerated&&(r.samples.length&&(f=oe(r,n,this._initPTS,this._initDTS)),i.samples.length&&(h=le(i,n,this._initPTS))),{audio:u,video:l,initSegment:d,independent:c,text:h,id3:f}},t.generateIS=function(e,t,r){var i,n,a,o=e.samples,l=t.samples,u=this.typeSupported,d={},h=!(0,s.isFiniteNumber)(this._initPTS),f="audio/mp4";if(h&&(i=n=1/0),e.config&&o.length&&(e.timescale=e.samplerate,"mp3"===e.segmentCodec&&(u.mpeg?(f="audio/mpeg",e.codec=""):u.mp3&&(e.codec="mp3")),d.audio={id:"audio",container:f,codec:e.codec,initSegment:"mp3"===e.segmentCodec&&u.mpeg?new Uint8Array(0):J.initSegment([e]),metadata:{channelCount:e.channelCount}},h&&(a=e.inputTimeScale,i=n=o[0].pts-Math.round(a*r))),t.sps&&t.pps&&l.length&&(t.timescale=t.inputTimeScale,d.video={id:"main",container:"video/mp4",codec:t.codec,initSegment:J.initSegment([t]),metadata:{width:t.width,height:t.height}},h)){a=t.inputTimeScale;var c=this.getVideoStartPts(l),v=Math.round(a*r);n=Math.min(n,se(l[0].dts,c)-v),i=Math.min(i,c-v)}if(Object.keys(d).length)return this.ISGenerated=!0,h&&(this._initPTS=i,this._initDTS=n),{tracks:d,initPTS:i,timescale:a}},t.remuxVideo=function(e,t,r,a){var s,o,l=e.inputTimeScale,u=e.samples,d=[],h=u.length,f=this._initPTS,c=this.nextAvcDts,v=8,p=this.videoSampleDuration,m=Number.POSITIVE_INFINITY,y=Number.NEGATIVE_INFINITY,E=!1;r&&null!==c||(c=t*l-(u[0].pts-se(u[0].dts,u[0].pts)));for(var T=0;T<h;T++){var S=u[T];S.pts=se(S.pts-f,c),S.dts=se(S.dts-f,c),S.dts<u[T>0?T-1:T].dts&&(E=!0)}E&&u.sort((function(e,t){var r=e.dts-t.dts,i=e.pts-t.pts;return r||i})),s=u[0].dts;var b=u[u.length-1].dts-s,L=b?Math.round(b/(h-1)):p||e.inputTimeScale/30;if(r){var A=s-c,D=A>L,R=A<-1;if((D||R)&&(D?g.logger.warn("AVC: "+te(A,!0)+" ms ("+A+"dts) hole between fragments detected, filling it"):g.logger.warn("AVC: "+te(-A,!0)+" ms ("+A+"dts) overlapping between fragments detected"),!R||c>u[0].pts)){s=c;var k=u[0].pts-A;u[0].dts=s,u[0].pts=k,g.logger.log("Video: First PTS/DTS adjusted: "+te(k,!0)+"/"+te(s,!0)+", delta: "+te(A,!0)+" ms")}}s=Math.max(0,s);for(var _=0,x=0,I=0;I<h;I++){for(var w=u[I],C=w.units,P=C.length,O=0,F=0;F<P;F++)O+=C[F].data.length;x+=O,_+=P,w.length=O,w.dts=Math.max(w.dts,s),m=Math.min(w.pts,m),y=Math.max(w.pts,y)}o=u[h-1].dts;var M,N=x+4*_+8;try{M=new Uint8Array(N)}catch(e){return void this.observer.emit(i.Events.ERROR,i.Events.ERROR,{type:n.ErrorTypes.MUX_ERROR,details:n.ErrorDetails.REMUX_ALLOC_ERROR,fatal:!1,bytes:N,reason:"fail allocating video mdat "+N})}var U=new DataView(M.buffer);U.setUint32(0,N),M.set(J.types.mdat,4);for(var B=!1,G=Number.POSITIVE_INFINITY,V=Number.POSITIVE_INFINITY,H=Number.NEGATIVE_INFINITY,j=Number.NEGATIVE_INFINITY,K=0;K<h;K++){for(var W=u[K],Y=W.units,q=0,z=0,X=Y.length;z<X;z++){var Q=Y[z],$=Q.data,Z=Q.data.byteLength;U.setUint32(v,Z),v+=4,M.set($,v),v+=Z,q+=4+Z}var ee=void 0;if(K<h-1)p=u[K+1].dts-W.dts,ee=u[K+1].pts-W.pts;else{var ae=this.config,oe=K>0?W.dts-u[K-1].dts:L;if(ee=K>0?W.pts-u[K-1].pts:L,ae.stretchShortVideoTrack&&null!==this.nextAudioPts){var le=Math.floor(ae.maxBufferHole*l),de=(a?m+a*l:this.nextAudioPts)-W.pts;de>le?((p=de-oe)<0?p=oe:B=!0,g.logger.log("[mp4-remuxer]: It is approximately "+de/90+" ms to the next segment; using duration "+p/90+" ms for the last video frame.")):p=oe}else p=oe}var he=Math.round(W.pts-W.dts);G=Math.min(G,p),H=Math.max(H,p),V=Math.min(V,ee),j=Math.max(j,ee),d.push(new ue(W.key,p,q,he))}if(d.length)if(ie){if(ie<70){var fe=d[0].flags;fe.dependsOn=2,fe.isNonSync=0}}else if(ne&&j-V<H-G&&L/H<.025&&0===d[0].cts){g.logger.warn("Found irregular gaps in sample duration. Using PTS instead of DTS to determine MP4 sample duration.");for(var ce=s,ve=0,ge=d.length;ve<ge;ve++){var pe=ce+d[ve].duration,me=ce+d[ve].cts;if(ve<ge-1){var ye=pe+d[ve+1].cts;d[ve].duration=ye-me}else d[ve].duration=ve?d[ve-1].duration:L;d[ve].cts=0,ce=pe}}p=B||!p?L:p,this.nextAvcDts=c=o+p,this.videoSampleDuration=p,this.isVideoContiguous=!0;var Ee={data1:J.moof(e.sequenceNumber++,s,re({},e,{samples:d})),data2:M,startPTS:m/l,endPTS:(y+p)/l,startDTS:s/l,endDTS:c/l,type:"video",hasAudio:!1,hasVideo:!0,nb:d.length,dropped:e.dropped};return e.samples=[],e.dropped=0,Ee},t.remuxAudio=function(e,t,r,a,s){var o=e.inputTimeScale,l=o/(e.samplerate?e.samplerate:o),u="aac"===e.segmentCodec?1024:1152,d=u*l,h=this._initPTS,f="mp3"===e.segmentCodec&&this.typeSupported.mpeg,c=[],v=void 0!==s,p=e.samples,m=f?0:8,y=this.nextAudioPts||-1,E=t*o;if(this.isAudioContiguous=r=r||p.length&&y>0&&(a&&Math.abs(E-y)<9e3||Math.abs(se(p[0].pts-h,E)-y)<20*d),p.forEach((function(e){e.pts=se(e.pts-h,E)})),!r||y<0){if(p=p.filter((function(e){return e.pts>=0})),!p.length)return;y=0===s?0:a&&!v?Math.max(0,E):p[0].pts}if("aac"===e.segmentCodec)for(var T=this.config.maxAudioFramesDrift,S=0,b=y;S<p.length;S++){var L=p[S],A=L.pts,D=A-b,R=Math.abs(1e3*D/o);if(D<=-T*d&&v)0===S&&(g.logger.warn("Audio frame @ "+(A/o).toFixed(3)+"s overlaps nextAudioPts by "+Math.round(1e3*D/o)+" ms."),this.nextAudioPts=y=b=A);else if(D>=T*d&&R<1e4&&v){var k=Math.round(D/d);(b=A-k*d)<0&&(k--,b+=d),0===S&&(this.nextAudioPts=y=b),g.logger.warn("[mp4-remuxer]: Injecting "+k+" audio frame @ "+(b/o).toFixed(3)+"s due to "+Math.round(1e3*D/o)+" ms gap.");for(var _=0;_<k;_++){var x=Math.max(b,0),I=Q.getSilentFrame(e.manifestCodec||e.codec,e.channelCount);I||(g.logger.log("[mp4-remuxer]: Unable to get silent frame for given audio codec; duplicating last frame instead."),I=L.unit.subarray()),p.splice(S,0,{unit:I,pts:x}),b+=d,S++}}L.pts=b,b+=d}for(var w,C=null,P=null,O=0,F=p.length;F--;)O+=p[F].unit.byteLength;for(var M=0,N=p.length;M<N;M++){var U=p[M],B=U.unit,G=U.pts;if(null!==P)c[M-1].duration=Math.round((G-P)/l);else{if(r&&"aac"===e.segmentCodec&&(G=y),C=G,!(O>0))return;O+=m;try{w=new Uint8Array(O)}catch(e){return void this.observer.emit(i.Events.ERROR,i.Events.ERROR,{type:n.ErrorTypes.MUX_ERROR,details:n.ErrorDetails.REMUX_ALLOC_ERROR,fatal:!1,bytes:O,reason:"fail allocating audio mdat "+O})}f||(new DataView(w.buffer).setUint32(0,O),w.set(J.types.mdat,4))}w.set(B,m);var V=B.byteLength;m+=V,c.push(new ue(!0,u,V,0)),P=G}var H=c.length;if(H){var j=c[c.length-1];this.nextAudioPts=y=P+l*j.duration;var K=f?new Uint8Array(0):J.moof(e.sequenceNumber++,C/l,re({},e,{samples:c}));e.samples=[];var W=C/o,Y=y/o,q={data1:K,data2:w,startPTS:W,endPTS:Y,startDTS:W,endDTS:Y,type:"audio",hasAudio:!0,hasVideo:!1,nb:H};return this.isAudioContiguous=!0,q}},t.remuxEmptyAudio=function(e,t,r,i){var n=e.inputTimeScale,a=n/(e.samplerate?e.samplerate:n),s=this.nextAudioPts,o=(null!==s?s:i.startDTS*n)+this._initDTS,l=i.endDTS*n+this._initDTS,u=1024*a,d=Math.ceil((l-o)/u),h=Q.getSilentFrame(e.manifestCodec||e.codec,e.channelCount);if(g.logger.warn("[mp4-remuxer]: remux empty Audio"),h){for(var f=[],c=0;c<d;c++){var v=o+c*u;f.push({unit:h,pts:v,dts:v})}return e.samples=f,this.remuxAudio(e,t,r,!1)}g.logger.trace("[mp4-remuxer]: Unable to remuxEmptyAudio since we were unable to get a silent frame for given audio codec")},e}();function se(e,t){var r;if(null===t)return e;for(r=t<e?-8589934592:8589934592;Math.abs(e-t)>4294967296;)e+=r;return e}function oe(e,t,r,i){var n=e.samples.length;if(n){for(var a=e.inputTimeScale,s=0;s<n;s++){var o=e.samples[s];o.pts=se(o.pts-r,t*a)/a,o.dts=se(o.dts-i,t*a)/a}var l=e.samples;return e.samples=[],{samples:l}}}function le(e,t,r){var i=e.samples.length;if(i){for(var n=e.inputTimeScale,a=0;a<i;a++){var s=e.samples[a];s.pts=se(s.pts-r,t*n)/n}e.samples.sort((function(e,t){return e.pts-t.pts}));var o=e.samples;return e.samples=[],{samples:o}}}var ue=function(e,t,r,i){this.size=void 0,this.duration=void 0,this.cts=void 0,this.flags=void 0,this.duration=t,this.size=r,this.cts=i,this.flags=new de(e)},de=function(e){this.isLeading=0,this.isDependedOn=0,this.hasRedundancy=0,this.degradPrio=0,this.dependsOn=1,this.isNonSync=1,this.dependsOn=e?2:1,this.isNonSync=e?0:1},he=r(923);function fe(e,t){var r=null==e?void 0:e.codec;return r&&r.length>4?r:"hvc1"===r||"hev1"===r?"hvc1.1.c.L120.90":"av01"===r?"av01.0.04M.08":"avc1"===r||t===he.ElementaryStreamTypes.VIDEO?"avc1.42e01e":"mp4a.40.5"}const ce=function(){function e(){this.emitInitSegment=!1,this.audioCodec=void 0,this.videoCodec=void 0,this.initData=void 0,this.initPTS=void 0,this.initTracks=void 0,this.lastEndTime=null}var t=e.prototype;return t.destroy=function(){},t.resetTimeStamp=function(e){this.initPTS=e,this.lastEndTime=null},t.resetNextTimestamp=function(){this.lastEndTime=null},t.resetInitSegment=function(e,t,r){this.audioCodec=t,this.videoCodec=r,this.generateInitSegment(e),this.emitInitSegment=!0},t.generateInitSegment=function(e){var t=this.audioCodec,r=this.videoCodec;if(!e||!e.byteLength)return this.initTracks=void 0,void(this.initData=void 0);var i=this.initData=(0,d.parseInitSegment)(e);t||(t=fe(i.audio,he.ElementaryStreamTypes.AUDIO)),r||(r=fe(i.video,he.ElementaryStreamTypes.VIDEO));var n={};i.audio&&i.video?n.audiovideo={container:"video/mp4",codec:t+","+r,initSegment:e,id:"main"}:i.audio?n.audio={container:"audio/mp4",codec:t,initSegment:e,id:"audio"}:i.video?n.video={container:"video/mp4",codec:r,initSegment:e,id:"main"}:g.logger.warn("[passthrough-remuxer.ts]: initSegment does not contain moov or trak boxes."),this.initTracks=n},t.remux=function(e,t,r,i,n){var a,o=this.initPTS,l=this.lastEndTime,u={audio:void 0,video:void 0,text:i,id3:r,initSegment:void 0};(0,s.isFiniteNumber)(l)||(l=this.lastEndTime=n||0);var h=t.samples;if(!h||!h.length)return u;var f={initPTS:void 0,timescale:1},c=this.initData;if(c&&c.length||(this.generateInitSegment(h),c=this.initData),!c||!c.length)return g.logger.warn("[passthrough-remuxer.ts]: Failed to generate initSegment."),u;this.emitInitSegment&&(f.tracks=this.initTracks,this.emitInitSegment=!1);var v=(0,d.getStartDTS)(c,h);(0,s.isFiniteNumber)(o)||(this.initPTS=f.initPTS=o=v-n);var p=(0,d.getDuration)(h,c),m=e?v-o:l,y=m+p;(0,d.offsetStartDTS)(c,h,o),p>0?this.lastEndTime=y:(g.logger.warn("Duration parsed from mp4 should be greater than zero"),this.resetNextTimestamp());var E=!!c.audio,T=!!c.video,S="";E&&(S+="audio"),T&&(S+="video");var b={data1:h,startPTS:m,startDTS:m,endPTS:y,endDTS:y,type:S,hasAudio:E,hasVideo:T,nb:1,dropped:0};u.audio="audio"===b.type?b:void 0,u.video="audio"!==b.type?b:void 0,u.initSegment=f;var L=null!=(a=this.initPTS)?a:0;return u.id3=oe(r,n,L,L),i.samples.length&&(u.text=le(i,n,L)),u},e}();var ve;try{ve=self.performance.now.bind(self.performance)}catch(e){g.logger.debug("Unable to use Performance API on this environment"),ve=self.Date.now}var ge=[{demux:q,remux:ae},{demux:k,remux:ce},{demux:D,remux:ae},{demux:X,remux:ae}],pe=function(){function e(e,t,r,i,n){this.observer=void 0,this.typeSupported=void 0,this.config=void 0,this.vendor=void 0,this.id=void 0,this.demuxer=void 0,this.remuxer=void 0,this.decrypter=void 0,this.probe=void 0,this.decryptionPromise=null,this.transmuxConfig=void 0,this.currentTransmuxState=void 0,this.observer=e,this.typeSupported=t,this.config=r,this.vendor=i,this.id=n}var t=e.prototype;return t.configure=function(e){this.transmuxConfig=e,this.decrypter&&this.decrypter.reset()},t.push=function(e,t,r,i){var n=this,a=r.transmuxing;a.executeStart=ve();var s=new Uint8Array(e),o=this.config,l=this.currentTransmuxState,u=this.transmuxConfig;i&&(this.currentTransmuxState=i);var d=i||l,h=d.contiguous,f=d.discontinuity,c=d.trackSwitch,v=d.accurateTimeOffset,g=d.timeOffset,p=d.initSegmentChange,m=u.audioCodec,y=u.videoCodec,E=u.defaultInitPts,T=u.duration,S=u.initSegmentData;(f||c||p)&&this.resetInitSegment(S,m,y,T),(f||p)&&this.resetInitialTimestamp(E),h||this.resetContiguity();var b=function(e,t){var r=null;return e.byteLength>0&&null!=t&&null!=t.key&&null!==t.iv&&null!=t.method&&(r=t),r}(s,t);if(b&&"AES-128"===b.method){var L=this.getDecrypter();if(!o.enableSoftwareAES)return this.decryptionPromise=L.webCryptoDecrypt(s,b.key.buffer,b.iv.buffer).then((function(e){var t=n.push(e,null,r);return n.decryptionPromise=null,t})),this.decryptionPromise;var A=L.softwareDecrypt(s,b.key.buffer,b.iv.buffer);if(!A)return a.executeEnd=ve(),me(r);s=new Uint8Array(A)}this.needsProbing(s,f,c)&&this.configureTransmuxer(s,u);var D=this.transmux(s,b,g,v,r),R=this.currentTransmuxState;return R.contiguous=!0,R.discontinuity=!1,R.trackSwitch=!1,a.executeEnd=ve(),D},t.flush=function(e){var t=this,r=e.transmuxing;r.executeStart=ve();var a=this.decrypter,s=this.currentTransmuxState,o=this.decryptionPromise;if(o)return o.then((function(){return t.flush(e)}));var l=[],u=s.timeOffset;if(a){var d=a.flush();d&&l.push(this.push(d,null,e))}var h=this.demuxer,f=this.remuxer;if(!h||!f)return this.observer.emit(i.Events.ERROR,i.Events.ERROR,{type:n.ErrorTypes.MEDIA_ERROR,details:n.ErrorDetails.FRAG_PARSING_ERROR,fatal:!0,reason:"no demux matching with content found"}),r.executeEnd=ve(),[me(e)];var c=h.flush(u);return ye(c)?c.then((function(r){return t.flushRemux(l,r,e),l})):(this.flushRemux(l,c,e),l)},t.flushRemux=function(e,t,r){var i=t.audioTrack,n=t.videoTrack,a=t.id3Track,s=t.textTrack,o=this.currentTransmuxState,l=o.accurateTimeOffset,u=o.timeOffset;g.logger.log("[transmuxer.ts]: Flushed fragment "+r.sn+(r.part>-1?" p: "+r.part:"")+" of level "+r.level);var d=this.remuxer.remux(i,n,a,s,u,l,!0,this.id);e.push({remuxResult:d,chunkMeta:r}),r.transmuxing.executeEnd=ve()},t.resetInitialTimestamp=function(e){var t=this.demuxer,r=this.remuxer;t&&r&&(t.resetTimeStamp(e),r.resetTimeStamp(e))},t.resetContiguity=function(){var e=this.demuxer,t=this.remuxer;e&&t&&(e.resetContiguity(),t.resetNextTimestamp())},t.resetInitSegment=function(e,t,r,i){var n=this.demuxer,a=this.remuxer;n&&a&&(n.resetInitSegment(e,t,r,i),a.resetInitSegment(e,t,r))},t.destroy=function(){this.demuxer&&(this.demuxer.destroy(),this.demuxer=void 0),this.remuxer&&(this.remuxer.destroy(),this.remuxer=void 0)},t.transmux=function(e,t,r,i,n){return t&&"SAMPLE-AES"===t.method?this.transmuxSampleAes(e,t,r,i,n):this.transmuxUnencrypted(e,r,i,n)},t.transmuxUnencrypted=function(e,t,r,i){var n=this.demuxer.demux(e,t,!1,!this.config.progressive),a=n.audioTrack,s=n.videoTrack,o=n.id3Track,l=n.textTrack;return{remuxResult:this.remuxer.remux(a,s,o,l,t,r,!1,this.id),chunkMeta:i}},t.transmuxSampleAes=function(e,t,r,i,n){var a=this;return this.demuxer.demuxSampleAes(e,t,r).then((function(e){return{remuxResult:a.remuxer.remux(e.audioTrack,e.videoTrack,e.id3Track,e.textTrack,r,i,!1,a.id),chunkMeta:n}}))},t.configureTransmuxer=function(e,t){for(var r,i=this.config,n=this.observer,a=this.typeSupported,s=this.vendor,o=t.audioCodec,l=t.defaultInitPts,u=t.duration,d=t.initSegmentData,h=t.videoCodec,f=0,c=ge.length;f<c;f++)if(ge[f].demux.probe(e)){r=ge[f];break}r||(g.logger.warn("Failed to find demuxer by probing frag, treating as mp4 passthrough"),r={demux:k,remux:ce});var v=this.demuxer,p=this.remuxer,m=r.remux,y=r.demux;p&&p instanceof m||(this.remuxer=new m(n,i,a,s)),v&&v instanceof y||(this.demuxer=new y(n,i,a),this.probe=y.probe),this.resetInitSegment(d,o,h,u),this.resetInitialTimestamp(l)},t.needsProbing=function(e,t,r){return!this.demuxer||!this.remuxer||t||r},t.getDecrypter=function(){var e=this.decrypter;return e||(e=this.decrypter=new a.default(this.observer,this.config)),e},e}(),me=function(e){return{remuxResult:{},chunkMeta:e}};function ye(e){return"then"in e&&e.then instanceof Function}var Ee=function(e,t,r,i,n){this.audioCodec=void 0,this.videoCodec=void 0,this.initSegmentData=void 0,this.duration=void 0,this.defaultInitPts=void 0,this.audioCodec=e,this.videoCodec=t,this.initSegmentData=r,this.duration=i,this.defaultInitPts=n},Te=function(e,t,r,i,n,a){this.discontinuity=void 0,this.contiguous=void 0,this.accurateTimeOffset=void 0,this.trackSwitch=void 0,this.timeOffset=void 0,this.initSegmentChange=void 0,this.discontinuity=e,this.contiguous=t,this.accurateTimeOffset=r,this.trackSwitch=i,this.timeOffset=n,this.initSegmentChange=a}},514:e=>{e.exports=void 0},973:(e,t,r)=>{"use strict";var i,n;r.r(t),r.d(t,{ErrorDetails:()=>n,ErrorTypes:()=>i}),function(e){e.NETWORK_ERROR="networkError",e.MEDIA_ERROR="mediaError",e.KEY_SYSTEM_ERROR="keySystemError",e.MUX_ERROR="muxError",e.OTHER_ERROR="otherError"}(i||(i={})),function(e){e.KEY_SYSTEM_NO_KEYS="keySystemNoKeys",e.KEY_SYSTEM_NO_ACCESS="keySystemNoAccess",e.KEY_SYSTEM_NO_SESSION="keySystemNoSession",e.KEY_SYSTEM_LICENSE_REQUEST_FAILED="keySystemLicenseRequestFailed",e.KEY_SYSTEM_NO_INIT_DATA="keySystemNoInitData",e.MANIFEST_LOAD_ERROR="manifestLoadError",e.MANIFEST_LOAD_TIMEOUT="manifestLoadTimeOut",e.MANIFEST_PARSING_ERROR="manifestParsingError",e.MANIFEST_INCOMPATIBLE_CODECS_ERROR="manifestIncompatibleCodecsError",e.LEVEL_EMPTY_ERROR="levelEmptyError",e.LEVEL_LOAD_ERROR="levelLoadError",e.LEVEL_LOAD_TIMEOUT="levelLoadTimeOut",e.LEVEL_SWITCH_ERROR="levelSwitchError",e.AUDIO_TRACK_LOAD_ERROR="audioTrackLoadError",e.AUDIO_TRACK_LOAD_TIMEOUT="audioTrackLoadTimeOut",e.SUBTITLE_LOAD_ERROR="subtitleTrackLoadError",e.SUBTITLE_TRACK_LOAD_TIMEOUT="subtitleTrackLoadTimeOut",e.FRAG_LOAD_ERROR="fragLoadError",e.FRAG_LOAD_TIMEOUT="fragLoadTimeOut",e.FRAG_DECRYPT_ERROR="fragDecryptError",e.FRAG_PARSING_ERROR="fragParsingError",e.REMUX_ALLOC_ERROR="remuxAllocError",e.KEY_LOAD_ERROR="keyLoadError",e.KEY_LOAD_TIMEOUT="keyLoadTimeOut",e.BUFFER_ADD_CODEC_ERROR="bufferAddCodecError",e.BUFFER_INCOMPATIBLE_CODECS_ERROR="bufferIncompatibleCodecsError",e.BUFFER_APPEND_ERROR="bufferAppendError",e.BUFFER_APPENDING_ERROR="bufferAppendingError",e.BUFFER_STALLED_ERROR="bufferStalledError",e.BUFFER_FULL_ERROR="bufferFullError",e.BUFFER_SEEK_OVER_HOLE="bufferSeekOverHole",e.BUFFER_NUDGE_ON_STALL="bufferNudgeOnStall",e.INTERNAL_EXCEPTION="internalException",e.INTERNAL_ABORTED="aborted",e.UNKNOWN="unknown"}(n||(n={}))},851:(e,t,r)=>{"use strict";var i;r.r(t),r.d(t,{Events:()=>i}),function(e){e.MEDIA_ATTACHING="hlsMediaAttaching",e.MEDIA_ATTACHED="hlsMediaAttached",e.MEDIA_DETACHING="hlsMediaDetaching",e.MEDIA_DETACHED="hlsMediaDetached",e.BUFFER_RESET="hlsBufferReset",e.BUFFER_CODECS="hlsBufferCodecs",e.BUFFER_CREATED="hlsBufferCreated",e.BUFFER_APPENDING="hlsBufferAppending",e.BUFFER_APPENDED="hlsBufferAppended",e.BUFFER_EOS="hlsBufferEos",e.BUFFER_FLUSHING="hlsBufferFlushing",e.BUFFER_FLUSHED="hlsBufferFlushed",e.MANIFEST_LOADING="hlsManifestLoading",e.MANIFEST_LOADED="hlsManifestLoaded",e.MANIFEST_PARSED="hlsManifestParsed",e.LEVEL_SWITCHING="hlsLevelSwitching",e.LEVEL_SWITCHED="hlsLevelSwitched",e.LEVEL_LOADING="hlsLevelLoading",e.LEVEL_LOADED="hlsLevelLoaded",e.LEVEL_UPDATED="hlsLevelUpdated",e.LEVEL_PTS_UPDATED="hlsLevelPtsUpdated",e.LEVELS_UPDATED="hlsLevelsUpdated",e.AUDIO_TRACKS_UPDATED="hlsAudioTracksUpdated",e.AUDIO_TRACK_SWITCHING="hlsAudioTrackSwitching",e.AUDIO_TRACK_SWITCHED="hlsAudioTrackSwitched",e.AUDIO_TRACK_LOADING="hlsAudioTrackLoading",e.AUDIO_TRACK_LOADED="hlsAudioTrackLoaded",e.SUBTITLE_TRACKS_UPDATED="hlsSubtitleTracksUpdated",e.SUBTITLE_TRACKS_CLEARED="hlsSubtitleTracksCleared",e.SUBTITLE_TRACK_SWITCH="hlsSubtitleTrackSwitch",e.SUBTITLE_TRACK_LOADING="hlsSubtitleTrackLoading",e.SUBTITLE_TRACK_LOADED="hlsSubtitleTrackLoaded",e.SUBTITLE_FRAG_PROCESSED="hlsSubtitleFragProcessed",e.CUES_PARSED="hlsCuesParsed",e.NON_NATIVE_TEXT_TRACKS_FOUND="hlsNonNativeTextTracksFound",e.INIT_PTS_FOUND="hlsInitPtsFound",e.FRAG_LOADING="hlsFragLoading",e.FRAG_LOAD_EMERGENCY_ABORTED="hlsFragLoadEmergencyAborted",e.FRAG_LOADED="hlsFragLoaded",e.FRAG_DECRYPTED="hlsFragDecrypted",e.FRAG_PARSING_INIT_SEGMENT="hlsFragParsingInitSegment",e.FRAG_PARSING_USERDATA="hlsFragParsingUserdata",e.FRAG_PARSING_METADATA="hlsFragParsingMetadata",e.FRAG_PARSED="hlsFragParsed",e.FRAG_BUFFERED="hlsFragBuffered",e.FRAG_CHANGED="hlsFragChanged",e.FPS_DROP="hlsFpsDrop",e.FPS_DROP_LEVEL_CAPPING="hlsFpsDropLevelCapping",e.ERROR="hlsError",e.DESTROYING="hlsDestroying",e.KEY_LOADING="hlsKeyLoading",e.KEY_LOADED="hlsKeyLoaded",e.LIVE_BACK_BUFFER_REACHED="hlsLiveBackBufferReached",e.BACK_BUFFER_REACHED="hlsBackBufferReached"}(i||(i={}))},392:(e,t,r)=>{"use strict";r.r(t),r.d(t,{default:()=>Ht});var i,n=r(945),a=r(965),s=r(851),o=r(973),l=r(93),u=r(63),d=/^(\d+)x(\d+)$/,h=/\s*(.+?)\s*=((?:\".*?\")|.*?)(?:,|$)/g,f=function(){function e(t){for(var r in"string"==typeof t&&(t=e.parseAttrList(t)),t)t.hasOwnProperty(r)&&(this[r]=t[r])}var t=e.prototype;return t.decimalInteger=function(e){var t=parseInt(this[e],10);return t>Number.MAX_SAFE_INTEGER?1/0:t},t.hexadecimalInteger=function(e){if(this[e]){var t=(this[e]||"0x").slice(2);t=(1&t.length?"0":"")+t;for(var r=new Uint8Array(t.length/2),i=0;i<t.length/2;i++)r[i]=parseInt(t.slice(2*i,2*i+2),16);return r}return null},t.hexadecimalIntegerAsNumber=function(e){var t=parseInt(this[e],16);return t>Number.MAX_SAFE_INTEGER?1/0:t},t.decimalFloatingPoint=function(e){return parseFloat(this[e])},t.optionalFloat=function(e,t){var r=this[e];return r?parseFloat(r):t},t.enumeratedString=function(e){return this[e]},t.bool=function(e){return"YES"===this[e]},t.decimalResolution=function(e){var t=d.exec(this[e]);if(null!==t)return{width:parseInt(t[1],10),height:parseInt(t[2],10)}},e.parseAttrList=function(e){var t,r={};for(h.lastIndex=0;null!==(t=h.exec(e));){var i=t[2];0===i.indexOf('"')&&i.lastIndexOf('"')===i.length-1&&(i=i.slice(1,-1)),r[t[1]]=i}return r},e}();function c(){return c=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},c.apply(this,arguments)}function v(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}!function(e){e.ID="ID",e.CLASS="CLASS",e.START_DATE="START-DATE",e.DURATION="DURATION",e.END_DATE="END-DATE",e.END_ON_NEXT="END-ON-NEXT",e.PLANNED_DURATION="PLANNED-DURATION",e.SCTE35_OUT="SCTE35-OUT",e.SCTE35_IN="SCTE35-IN"}(i||(i={}));var g=function(){function e(e,t){if(this.attr=void 0,this._startDate=void 0,this._endDate=void 0,this._badValueForSameId=void 0,t){var r=t.attr;for(var n in r)if(Object.prototype.hasOwnProperty.call(e,n)&&e[n]!==r[n]){l.logger.warn('DATERANGE tag attribute: "'+n+'" does not match for tags with ID: "'+e.ID+'"'),this._badValueForSameId=n;break}e=c(new f({}),r,e)}if(this.attr=e,this._startDate=new Date(e[i.START_DATE]),i.END_DATE in this.attr){var s=new Date(this.attr[i.END_DATE]);(0,a.isFiniteNumber)(s.getTime())&&(this._endDate=s)}}var t,r;return t=e,(r=[{key:"id",get:function(){return this.attr.ID}},{key:"class",get:function(){return this.attr.CLASS}},{key:"startDate",get:function(){return this._startDate}},{key:"endDate",get:function(){if(this._endDate)return this._endDate;var e=this.duration;return null!==e?new Date(this._startDate.getTime()+1e3*e):null}},{key:"duration",get:function(){if(i.DURATION in this.attr){var e=this.attr.decimalFloatingPoint(i.DURATION);if((0,a.isFiniteNumber)(e))return e}else if(this._endDate)return(this._endDate.getTime()-this._startDate.getTime())/1e3;return null}},{key:"plannedDuration",get:function(){return i.PLANNED_DURATION in this.attr?this.attr.decimalFloatingPoint(i.PLANNED_DURATION):null}},{key:"endOnNext",get:function(){return this.attr.bool(i.END_ON_NEXT)}},{key:"isValid",get:function(){return!!this.id&&!this._badValueForSameId&&(0,a.isFiniteNumber)(this.startDate.getTime())&&(null===this.duration||this.duration>=0)&&(!this.endOnNext||!!this.class)}}])&&v(t.prototype,r),Object.defineProperty(t,"prototype",{writable:!1}),e}(),p=r(923);function m(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}var y=function(){function e(e){this.PTSKnown=!1,this.alignedSliding=!1,this.averagetargetduration=void 0,this.endCC=0,this.endSN=0,this.fragments=void 0,this.fragmentHint=void 0,this.partList=null,this.dateRanges=void 0,this.live=!0,this.ageHeader=0,this.advancedDateTime=void 0,this.updated=!0,this.advanced=!0,this.availabilityDelay=void 0,this.misses=0,this.needSidxRanges=!1,this.startCC=0,this.startSN=0,this.startTimeOffset=null,this.targetduration=0,this.totalduration=0,this.type=null,this.url=void 0,this.m3u8="",this.version=null,this.canBlockReload=!1,this.canSkipUntil=0,this.canSkipDateRanges=!1,this.skippedSegments=0,this.recentlyRemovedDateranges=void 0,this.partHoldBack=0,this.holdBack=0,this.partTarget=0,this.preloadHint=void 0,this.renditionReports=void 0,this.tuneInGoal=0,this.deltaUpdateFailed=void 0,this.driftStartTime=0,this.driftEndTime=0,this.driftStart=0,this.driftEnd=0,this.fragments=[],this.dateRanges={},this.url=e}var t,r;return e.prototype.reloaded=function(e){if(!e)return this.advanced=!0,void(this.updated=!0);var t=this.lastPartSn-e.lastPartSn,r=this.lastPartIndex-e.lastPartIndex;this.updated=this.endSN!==e.endSN||!!r||!!t,this.advanced=this.endSN>e.endSN||t>0||0===t&&r>0,this.updated||this.advanced?this.misses=Math.floor(.6*e.misses):this.misses=e.misses+1,this.availabilityDelay=e.availabilityDelay},t=e,(r=[{key:"hasProgramDateTime",get:function(){return!!this.fragments.length&&(0,a.isFiniteNumber)(this.fragments[this.fragments.length-1].programDateTime)}},{key:"levelTargetDuration",get:function(){return this.averagetargetduration||this.targetduration||10}},{key:"drift",get:function(){var e=this.driftEndTime-this.driftStartTime;return e>0?1e3*(this.driftEnd-this.driftStart)/e:1}},{key:"edge",get:function(){return this.partEnd||this.fragmentEnd}},{key:"partEnd",get:function(){var e;return null!==(e=this.partList)&&void 0!==e&&e.length?this.partList[this.partList.length-1].end:this.fragmentEnd}},{key:"fragmentEnd",get:function(){var e;return null!==(e=this.fragments)&&void 0!==e&&e.length?this.fragments[this.fragments.length-1].end:0}},{key:"age",get:function(){return this.advancedDateTime?Math.max(Date.now()-this.advancedDateTime,0)/1e3:0}},{key:"lastPartIndex",get:function(){var e;return null!==(e=this.partList)&&void 0!==e&&e.length?this.partList[this.partList.length-1].index:-1}},{key:"lastPartSn",get:function(){var e;return null!==(e=this.partList)&&void 0!==e&&e.length?this.partList[this.partList.length-1].fragment.sn:this.endSN}}])&&m(t.prototype,r),Object.defineProperty(t,"prototype",{writable:!1}),e}(),E=r(960),T={audio:{a3ds:!0,"ac-3":!0,"ac-4":!0,alac:!0,alaw:!0,dra1:!0,"dts+":!0,"dts-":!0,dtsc:!0,dtse:!0,dtsh:!0,"ec-3":!0,enca:!0,g719:!0,g726:!0,m4ae:!0,mha1:!0,mha2:!0,mhm1:!0,mhm2:!0,mlpa:!0,mp4a:!0,"raw ":!0,Opus:!0,opus:!0,samr:!0,sawb:!0,sawp:!0,sevc:!0,sqcp:!0,ssmv:!0,twos:!0,ulaw:!0},video:{avc1:!0,avc2:!0,avc3:!0,avc4:!0,avcp:!0,av01:!0,drac:!0,dva1:!0,dvav:!0,dvh1:!0,dvhe:!0,encv:!0,hev1:!0,hvc1:!0,mjp2:!0,mp4v:!0,mvc1:!0,mvc2:!0,mvc3:!0,mvc4:!0,resv:!0,rv60:!0,s263:!0,svc1:!0,svc2:!0,"vc-1":!0,vp08:!0,vp09:!0},text:{stpp:!0,wvtt:!0}};function S(e,t){return MediaSource.isTypeSupported((t||"video")+'/mp4;codecs="'+e+'"')}var b=/#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-SESSION-DATA:([^\r\n]*)[\r\n]+/g,L=/#EXT-X-MEDIA:(.*)/g,A=new RegExp([/#EXTINF:\s*(\d*(?:\.\d+)?)(?:,(.*)\s+)?/.source,/(?!#) *(\S[\S ]*)/.source,/#EXT-X-BYTERANGE:*(.+)/.source,/#EXT-X-PROGRAM-DATE-TIME:(.+)/.source,/#.*/.source].join("|"),"g"),D=new RegExp([/#(EXTM3U)/.source,/#EXT-X-(DATERANGE|KEY|MAP|PART|PART-INF|PLAYLIST-TYPE|PRELOAD-HINT|RENDITION-REPORT|SERVER-CONTROL|SKIP|START):(.+)/.source,/#EXT-X-(BITRATE|DISCONTINUITY-SEQUENCE|MEDIA-SEQUENCE|TARGETDURATION|VERSION): *(\d+)/.source,/#EXT-X-(DISCONTINUITY|ENDLIST|GAP)/.source,/(#)([^:]*):(.*)/.source,/(#)(.*)(?:.*)\r?\n?/.source].join("|")),R=/\.(mp4|m4s|m4v|m4a)$/i,k=function(){function e(){}return e.findGroup=function(e,t){for(var r=0;r<e.length;r++){var i=e[r];if(i.id===t)return i}},e.convertAVC1ToAVCOTI=function(e){var t=e.split(".");if(t.length>2){var r=t.shift()+".";return(r+=parseInt(t.shift()).toString(16))+("000"+parseInt(t.shift()).toString(16)).slice(-4)}return e},e.resolve=function(e,t){return n.buildAbsoluteURL(t,e,{alwaysNormalize:!0})},e.parseMasterPlaylist=function(t,r){var i,n=[],a=[],s={},o=!1;for(b.lastIndex=0;null!=(i=b.exec(t));)if(i[1]){var l,u=new f(i[1]),d={attrs:u,bitrate:u.decimalInteger("AVERAGE-BANDWIDTH")||u.decimalInteger("BANDWIDTH"),name:u.NAME,url:e.resolve(i[2],r)},h=u.decimalResolution("RESOLUTION");h&&(d.width=h.width,d.height=h.height),_((u.CODECS||"").split(/[ ,]+/).filter((function(e){return e})),d),d.videoCodec&&-1!==d.videoCodec.indexOf("avc1")&&(d.videoCodec=e.convertAVC1ToAVCOTI(d.videoCodec)),null!==(l=d.unknownCodecs)&&void 0!==l&&l.length||a.push(d),n.push(d)}else if(i[3]){var c=new f(i[3]);c["DATA-ID"]&&(o=!0,s[c["DATA-ID"]]=c)}return{levels:a.length>0&&a.length<n.length?a:n,sessionData:o?s:null}},e.parseMasterPlaylistMedia=function(t,r,i,n){var a;void 0===n&&(n=[]);var s=[],o=0;for(L.lastIndex=0;null!==(a=L.exec(t));){var l=new f(a[1]);if(l.TYPE===i){var u={attrs:l,bitrate:0,id:o++,groupId:l["GROUP-ID"],instreamId:l["INSTREAM-ID"],name:l.NAME||l.LANGUAGE||"",type:i,default:l.bool("DEFAULT"),autoselect:l.bool("AUTOSELECT"),forced:l.bool("FORCED"),lang:l.LANGUAGE,url:l.URI?e.resolve(l.URI,r):""};if(n.length){var d=e.findGroup(n,u.groupId)||n[0];x(u,d,"audioCodec"),x(u,d,"textCodec")}s.push(u)}}return s},e.parseLevelPlaylist=function(e,t,r,i,s){var o,u,d,h=new y(t),c=h.fragments,v=null,m=0,T=0,S=0,b=0,L=null,k=new p.Fragment(i,t),_=-1,x=!1;for(A.lastIndex=0,h.m3u8=e;null!==(o=A.exec(e));){x&&(x=!1,(k=new p.Fragment(i,t)).start=S,k.sn=m,k.cc=b,k.level=r,v&&(k.initSegment=v,k.rawProgramDateTime=v.rawProgramDateTime,v.rawProgramDateTime=null));var C=o[1];if(C){k.duration=parseFloat(C);var P=(" "+o[2]).slice(1);k.title=P||null,k.tagList.push(P?["INF",C,P]:["INF",C])}else if(o[3])(0,a.isFiniteNumber)(k.duration)&&(k.start=S,d&&(k.levelkey=d),k.sn=m,k.level=r,k.cc=b,k.urlId=s,c.push(k),k.relurl=(" "+o[3]).slice(1),I(k,L),L=k,S+=k.duration,m++,T=0,x=!0);else if(o[4]){var O=(" "+o[4]).slice(1);L?k.setByteRange(O,L):k.setByteRange(O)}else if(o[5])k.rawProgramDateTime=(" "+o[5]).slice(1),k.tagList.push(["PROGRAM-DATE-TIME",k.rawProgramDateTime]),-1===_&&(_=c.length);else{if(!(o=o[0].match(D))){l.logger.warn("No matches on slow regex match for level playlist!");continue}for(u=1;u<o.length&&void 0===o[u];u++);var F=(" "+o[u]).slice(1),M=(" "+o[u+1]).slice(1),N=o[u+2]?(" "+o[u+2]).slice(1):"";switch(F){case"PLAYLIST-TYPE":h.type=M.toUpperCase();break;case"MEDIA-SEQUENCE":m=h.startSN=parseInt(M);break;case"SKIP":var U=new f(M),B=U.decimalInteger("SKIPPED-SEGMENTS");if((0,a.isFiniteNumber)(B)){h.skippedSegments=B;for(var G=B;G--;)c.unshift(null);m+=B}var V=U.enumeratedString("RECENTLY-REMOVED-DATERANGES");V&&(h.recentlyRemovedDateranges=V.split("\t"));break;case"TARGETDURATION":h.targetduration=parseFloat(M);break;case"VERSION":h.version=parseInt(M);break;case"EXTM3U":break;case"ENDLIST":h.live=!1;break;case"#":(M||N)&&k.tagList.push(N?[M,N]:[M]);break;case"DISCONTINUITY":b++,k.tagList.push(["DIS"]);break;case"GAP":k.tagList.push([F]);break;case"BITRATE":k.tagList.push([F,M]);break;case"DATERANGE":var H=new f(M),j=new g(H,h.dateRanges[H.ID]);j.isValid||h.skippedSegments?h.dateRanges[j.id]=j:l.logger.warn('Ignoring invalid DATERANGE tag: "'+M+'"'),k.tagList.push(["EXT-X-DATERANGE",M]);break;case"DISCONTINUITY-SEQUENCE":b=parseInt(M);break;case"KEY":var K,W=new f(M),Y=W.enumeratedString("METHOD"),q=W.URI,z=W.hexadecimalInteger("IV"),X=W.enumeratedString("KEYFORMATVERSIONS"),Q=W.enumeratedString("KEYID"),$=null!=(K=W.enumeratedString("KEYFORMAT"))?K:"identity";if(["com.apple.streamingkeydelivery","com.microsoft.playready","urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed","com.widevine"].indexOf($)>-1){l.logger.warn("Keyformat "+$+" is not supported from the manifest");continue}if("identity"!==$)continue;Y&&(d=E.LevelKey.fromURL(t,q),q&&["AES-128","SAMPLE-AES","SAMPLE-AES-CENC"].indexOf(Y)>=0&&(d.method=Y,d.keyFormat=$,Q&&(d.keyID=Q),X&&(d.keyFormatVersions=X),d.iv=z));break;case"START":var Z=new f(M).decimalFloatingPoint("TIME-OFFSET");(0,a.isFiniteNumber)(Z)&&(h.startTimeOffset=Z);break;case"MAP":var J=new f(M);if(k.duration){var ee=new p.Fragment(i,t);w(ee,J,r,d),v=ee,k.initSegment=v,v.rawProgramDateTime&&!k.rawProgramDateTime&&(k.rawProgramDateTime=v.rawProgramDateTime)}else w(k,J,r,d),v=k,x=!0;break;case"SERVER-CONTROL":var te=new f(M);h.canBlockReload=te.bool("CAN-BLOCK-RELOAD"),h.canSkipUntil=te.optionalFloat("CAN-SKIP-UNTIL",0),h.canSkipDateRanges=h.canSkipUntil>0&&te.bool("CAN-SKIP-DATERANGES"),h.partHoldBack=te.optionalFloat("PART-HOLD-BACK",0),h.holdBack=te.optionalFloat("HOLD-BACK",0);break;case"PART-INF":var re=new f(M);h.partTarget=re.decimalFloatingPoint("PART-TARGET");break;case"PART":var ie=h.partList;ie||(ie=h.partList=[]);var ne=T>0?ie[ie.length-1]:void 0,ae=T++,se=new p.Part(new f(M),k,t,ae,ne);ie.push(se),k.duration+=se.duration;break;case"PRELOAD-HINT":var oe=new f(M);h.preloadHint=oe;break;case"RENDITION-REPORT":var le=new f(M);h.renditionReports=h.renditionReports||[],h.renditionReports.push(le);break;default:l.logger.warn("line parsed but not handled: "+o)}}}L&&!L.relurl?(c.pop(),S-=L.duration,h.partList&&(h.fragmentHint=L)):h.partList&&(I(k,L),k.cc=b,h.fragmentHint=k);var ue=c.length,de=c[0],he=c[ue-1];if((S+=h.skippedSegments*h.targetduration)>0&&ue&&he){h.averagetargetduration=S/ue;var fe=he.sn;h.endSN="initSegment"!==fe?fe:0,de&&(h.startCC=de.cc,de.initSegment||h.fragments.every((function(e){return e.relurl&&(t=e.relurl,R.test(null!=(r=null===(i=n.parseURL(t))||void 0===i?void 0:i.path)?r:""));var t,r,i}))&&(l.logger.warn("MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX"),(k=new p.Fragment(i,t)).relurl=he.relurl,k.level=r,k.sn="initSegment",de.initSegment=k,h.needSidxRanges=!0))}else h.endSN=0,h.startCC=0;return h.fragmentHint&&(S+=h.fragmentHint.duration),h.totalduration=S,h.endCC=b,_>0&&function(e,t){for(var r=e[t],i=t;i--;){var n=e[i];if(!n)return;n.programDateTime=r.programDateTime-1e3*n.duration,r=n}}(c,_),h},e}();function _(e,t){["video","audio","text"].forEach((function(r){var i=e.filter((function(e){return function(e,t){var r=T[t];return!!r&&!0===r[e.slice(0,4)]}(e,r)}));if(i.length){var n=i.filter((function(e){return 0===e.lastIndexOf("avc1",0)||0===e.lastIndexOf("mp4a",0)}));t[r+"Codec"]=n.length>0?n[0]:i[0],e=e.filter((function(e){return-1===i.indexOf(e)}))}})),t.unknownCodecs=e}function x(e,t,r){var i=t[r];i&&(e[r]=i)}function I(e,t){e.rawProgramDateTime?e.programDateTime=Date.parse(e.rawProgramDateTime):null!=t&&t.programDateTime&&(e.programDateTime=t.endProgramDateTime),(0,a.isFiniteNumber)(e.programDateTime)||(e.programDateTime=null,e.rawProgramDateTime=null)}function w(e,t,r,i){e.relurl=t.URI,t.BYTERANGE&&e.setByteRange(t.BYTERANGE),e.level=r,e.sn="initSegment",i&&(e.levelkey=i),e.initSegment=null}var C=r(308);function P(e,t){var r=e.url;return void 0!==r&&0!==r.indexOf("data:")||(r=t.url),r}const O=function(){function e(e){this.hls=void 0,this.loaders=Object.create(null),this.hls=e,this.registerListeners()}var t=e.prototype;return t.startLoad=function(e){},t.stopLoad=function(){this.destroyInternalLoaders()},t.registerListeners=function(){var e=this.hls;e.on(s.Events.MANIFEST_LOADING,this.onManifestLoading,this),e.on(s.Events.LEVEL_LOADING,this.onLevelLoading,this),e.on(s.Events.AUDIO_TRACK_LOADING,this.onAudioTrackLoading,this),e.on(s.Events.SUBTITLE_TRACK_LOADING,this.onSubtitleTrackLoading,this)},t.unregisterListeners=function(){var e=this.hls;e.off(s.Events.MANIFEST_LOADING,this.onManifestLoading,this),e.off(s.Events.LEVEL_LOADING,this.onLevelLoading,this),e.off(s.Events.AUDIO_TRACK_LOADING,this.onAudioTrackLoading,this),e.off(s.Events.SUBTITLE_TRACK_LOADING,this.onSubtitleTrackLoading,this)},t.createInternalLoader=function(e){var t=this.hls.config,r=t.pLoader,i=t.loader,n=new(r||i)(t);return e.loader=n,this.loaders[e.type]=n,n},t.getInternalLoader=function(e){return this.loaders[e.type]},t.resetInternalLoader=function(e){this.loaders[e]&&delete this.loaders[e]},t.destroyInternalLoaders=function(){for(var e in this.loaders){var t=this.loaders[e];t&&t.destroy(),this.resetInternalLoader(e)}},t.destroy=function(){this.unregisterListeners(),this.destroyInternalLoaders()},t.onManifestLoading=function(e,t){var r=t.url;this.load({id:null,groupId:null,level:0,responseType:"text",type:C.PlaylistContextType.MANIFEST,url:r,deliveryDirectives:null})},t.onLevelLoading=function(e,t){var r=t.id,i=t.level,n=t.url,a=t.deliveryDirectives;this.load({id:r,groupId:null,level:i,responseType:"text",type:C.PlaylistContextType.LEVEL,url:n,deliveryDirectives:a})},t.onAudioTrackLoading=function(e,t){var r=t.id,i=t.groupId,n=t.url,a=t.deliveryDirectives;this.load({id:r,groupId:i,level:null,responseType:"text",type:C.PlaylistContextType.AUDIO_TRACK,url:n,deliveryDirectives:a})},t.onSubtitleTrackLoading=function(e,t){var r=t.id,i=t.groupId,n=t.url,a=t.deliveryDirectives;this.load({id:r,groupId:i,level:null,responseType:"text",type:C.PlaylistContextType.SUBTITLE_TRACK,url:n,deliveryDirectives:a})},t.load=function(e){var t,r,i,n,a,s,o=this.hls.config,u=this.getInternalLoader(e);if(u){var d=u.context;if(d&&d.url===e.url)return void l.logger.trace("[playlist-loader]: playlist request ongoing");l.logger.log("[playlist-loader]: aborting previous loader for type: "+e.type),u.abort()}switch(e.type){case C.PlaylistContextType.MANIFEST:r=o.manifestLoadingMaxRetry,i=o.manifestLoadingTimeOut,n=o.manifestLoadingRetryDelay,a=o.manifestLoadingMaxRetryTimeout;break;case C.PlaylistContextType.LEVEL:case C.PlaylistContextType.AUDIO_TRACK:case C.PlaylistContextType.SUBTITLE_TRACK:r=0,i=o.levelLoadingTimeOut;break;default:r=o.levelLoadingMaxRetry,i=o.levelLoadingTimeOut,n=o.levelLoadingRetryDelay,a=o.levelLoadingMaxRetryTimeout}if(u=this.createInternalLoader(e),null!==(t=e.deliveryDirectives)&&void 0!==t&&t.part&&(e.type===C.PlaylistContextType.LEVEL&&null!==e.level?s=this.hls.levels[e.level].details:e.type===C.PlaylistContextType.AUDIO_TRACK&&null!==e.id?s=this.hls.audioTracks[e.id].details:e.type===C.PlaylistContextType.SUBTITLE_TRACK&&null!==e.id&&(s=this.hls.subtitleTracks[e.id].details),s)){var h=s.partTarget,f=s.targetduration;h&&f&&(i=Math.min(1e3*Math.max(3*h,.8*f),i))}var c={timeout:i,maxRetry:r,retryDelay:n,maxRetryDelay:a,highWaterMark:0},v={onSuccess:this.loadsuccess.bind(this),onError:this.loaderror.bind(this),onTimeout:this.loadtimeout.bind(this)};u.load(e,c,v)},t.loadsuccess=function(e,t,r,i){if(void 0===i&&(i=null),r.isSidxRequest)return this.handleSidxRequest(e,r),void this.handlePlaylistLoaded(e,t,r,i);this.resetInternalLoader(r.type);var n=e.data;0===n.indexOf("#EXTM3U")?(t.parsing.start=performance.now(),n.indexOf("#EXTINF:")>0||n.indexOf("#EXT-X-TARGETDURATION:")>0?this.handleTrackOrLevelPlaylist(e,t,r,i):this.handleMasterPlaylist(e,t,r,i)):this.handleManifestParsingError(e,r,"no EXTM3U delimiter",i)},t.loaderror=function(e,t,r){void 0===r&&(r=null),this.handleNetworkError(t,r,!1,e)},t.loadtimeout=function(e,t,r){void 0===r&&(r=null),this.handleNetworkError(t,r,!0)},t.handleMasterPlaylist=function(e,t,r,i){var n=this.hls,a=e.data,o=P(e,r),u=k.parseMasterPlaylist(a,o),d=u.levels,h=u.sessionData;if(d.length){var c=d.map((function(e){return{id:e.attrs.AUDIO,audioCodec:e.audioCodec}})),v=d.map((function(e){return{id:e.attrs.SUBTITLES,textCodec:e.textCodec}})),g=k.parseMasterPlaylistMedia(a,o,"AUDIO",c),p=k.parseMasterPlaylistMedia(a,o,"SUBTITLES",v),m=k.parseMasterPlaylistMedia(a,o,"CLOSED-CAPTIONS");g.length&&(g.some((function(e){return!e.url}))||!d[0].audioCodec||d[0].attrs.AUDIO||(l.logger.log("[playlist-loader]: audio codec signaled in quality level, but no embedded audio track signaled, create one"),g.unshift({type:"main",name:"main",default:!1,autoselect:!1,forced:!1,id:-1,attrs:new f({}),bitrate:0,url:""}))),n.trigger(s.Events.MANIFEST_LOADED,{levels:d,audioTracks:g,subtitles:p,captions:m,url:o,stats:t,networkDetails:i,sessionData:h})}else this.handleManifestParsingError(e,r,"no level found in manifest",i)},t.handleTrackOrLevelPlaylist=function(e,t,r,i){var n=this.hls,l=r.id,u=r.level,d=r.type,h=P(e,r),c=(0,a.isFiniteNumber)(l)?l:0,v=(0,a.isFiniteNumber)(u)?u:c,g=function(e){switch(e.type){case C.PlaylistContextType.AUDIO_TRACK:return C.PlaylistLevelType.AUDIO;case C.PlaylistContextType.SUBTITLE_TRACK:return C.PlaylistLevelType.SUBTITLE;default:return C.PlaylistLevelType.MAIN}}(r),p=k.parseLevelPlaylist(e.data,h,v,g,c);if(p.fragments.length){if(d===C.PlaylistContextType.MANIFEST){var m={attrs:new f({}),bitrate:0,details:p,name:"",url:h};n.trigger(s.Events.MANIFEST_LOADED,{levels:[m],audioTracks:[],url:h,stats:t,networkDetails:i,sessionData:null})}if(t.parsing.end=performance.now(),p.needSidxRanges){var y,E=null===(y=p.fragments[0].initSegment)||void 0===y?void 0:y.url;this.load({url:E,isSidxRequest:!0,type:d,level:u,levelDetails:p,id:l,groupId:null,rangeStart:0,rangeEnd:2048,responseType:"arraybuffer",deliveryDirectives:null})}else r.levelDetails=p,this.handlePlaylistLoaded(e,t,r,i)}else n.trigger(s.Events.ERROR,{type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.LEVEL_EMPTY_ERROR,fatal:!1,url:h,reason:"no fragments found in level",level:"number"==typeof r.level?r.level:void 0})},t.handleSidxRequest=function(e,t){var r=new Uint8Array(e.data),i=(0,u.findBox)(r,["sidx"])[0];if(i){var n=(0,u.parseSegmentIndex)(i);if(n){var a=n.references,s=t.levelDetails;a.forEach((function(e,t){var i=e.info,n=s.fragments[t];if(0===n.byteRange.length&&n.setByteRange(String(1+i.end-i.start)+"@"+String(i.start)),n.initSegment){var a=(0,u.findBox)(r,["moov"])[0],o=a?a.length:null;n.initSegment.setByteRange(String(o)+"@0")}}))}}},t.handleManifestParsingError=function(e,t,r,i){this.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.MANIFEST_PARSING_ERROR,fatal:t.type===C.PlaylistContextType.MANIFEST,url:e.url,reason:r,response:e,context:t,networkDetails:i})},t.handleNetworkError=function(e,t,r,i){void 0===r&&(r=!1),l.logger.warn("[playlist-loader]: A network "+(r?"timeout":"error")+" occurred while loading "+e.type+" level: "+e.level+" id: "+e.id+' group-id: "'+e.groupId+'"');var n=o.ErrorDetails.UNKNOWN,a=!1,u=this.getInternalLoader(e);switch(e.type){case C.PlaylistContextType.MANIFEST:n=r?o.ErrorDetails.MANIFEST_LOAD_TIMEOUT:o.ErrorDetails.MANIFEST_LOAD_ERROR,a=!0;break;case C.PlaylistContextType.LEVEL:n=r?o.ErrorDetails.LEVEL_LOAD_TIMEOUT:o.ErrorDetails.LEVEL_LOAD_ERROR,a=!1;break;case C.PlaylistContextType.AUDIO_TRACK:n=r?o.ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT:o.ErrorDetails.AUDIO_TRACK_LOAD_ERROR,a=!1;break;case C.PlaylistContextType.SUBTITLE_TRACK:n=r?o.ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT:o.ErrorDetails.SUBTITLE_LOAD_ERROR,a=!1}u&&this.resetInternalLoader(e.type);var d={type:o.ErrorTypes.NETWORK_ERROR,details:n,fatal:a,url:e.url,loader:u,context:e,networkDetails:t};i&&(d.response=i),this.hls.trigger(s.Events.ERROR,d)},t.handlePlaylistLoaded=function(e,t,r,i){var n=r.type,a=r.level,o=r.id,l=r.groupId,u=r.loader,d=r.levelDetails,h=r.deliveryDirectives;if(null!=d&&d.targetduration){if(u)switch(d.live&&(u.getCacheAge&&(d.ageHeader=u.getCacheAge()||0),u.getCacheAge&&!isNaN(d.ageHeader)||(d.ageHeader=0)),n){case C.PlaylistContextType.MANIFEST:case C.PlaylistContextType.LEVEL:this.hls.trigger(s.Events.LEVEL_LOADED,{details:d,level:a||0,id:o||0,stats:t,networkDetails:i,deliveryDirectives:h});break;case C.PlaylistContextType.AUDIO_TRACK:this.hls.trigger(s.Events.AUDIO_TRACK_LOADED,{details:d,id:o||0,groupId:l||"",stats:t,networkDetails:i,deliveryDirectives:h});break;case C.PlaylistContextType.SUBTITLE_TRACK:this.hls.trigger(s.Events.SUBTITLE_TRACK_LOADED,{details:d,id:o||0,groupId:l||"",stats:t,networkDetails:i,deliveryDirectives:h})}}else this.handleManifestParsingError(e,r,"invalid target duration",i)},e}();var F=function(){function e(e){this.hls=void 0,this.loaders={},this.decryptkey=null,this.decrypturl=null,this.hls=e,this.registerListeners()}var t=e.prototype;return t.startLoad=function(e){},t.stopLoad=function(){this.destroyInternalLoaders()},t.registerListeners=function(){this.hls.on(s.Events.KEY_LOADING,this.onKeyLoading,this)},t.unregisterListeners=function(){this.hls.off(s.Events.KEY_LOADING,this.onKeyLoading)},t.destroyInternalLoaders=function(){for(var e in this.loaders){var t=this.loaders[e];t&&t.destroy()}this.loaders={}},t.destroy=function(){this.unregisterListeners(),this.destroyInternalLoaders()},t.onKeyLoading=function(e,t){var r=t.frag,i=r.type,n=this.loaders[i];if(r.decryptdata){var a=r.decryptdata.uri;if(a!==this.decrypturl||null===this.decryptkey){var o=this.hls.config;if(n&&(l.logger.warn("abort previous key loader for type:"+i),n.abort()),!a)return void l.logger.warn("key uri is falsy");var u=o.loader,d=r.loader=this.loaders[i]=new u(o);this.decrypturl=a,this.decryptkey=null;var h={url:a,frag:r,responseType:"arraybuffer"},f={timeout:o.fragLoadingTimeOut,maxRetry:0,retryDelay:o.fragLoadingRetryDelay,maxRetryDelay:o.fragLoadingMaxRetryTimeout,highWaterMark:0},c={onSuccess:this.loadsuccess.bind(this),onError:this.loaderror.bind(this),onTimeout:this.loadtimeout.bind(this)};d.load(h,f,c)}else this.decryptkey&&(r.decryptdata.key=this.decryptkey,this.hls.trigger(s.Events.KEY_LOADED,{frag:r}))}else l.logger.warn("Missing decryption data on fragment in onKeyLoading")},t.loadsuccess=function(e,t,r){var i=r.frag;i.decryptdata?(this.decryptkey=i.decryptdata.key=new Uint8Array(e.data),i.loader=null,delete this.loaders[i.type],this.hls.trigger(s.Events.KEY_LOADED,{frag:i})):l.logger.error("after key load, decryptdata unset")},t.loaderror=function(e,t){var r=t.frag,i=r.loader;i&&i.abort(),delete this.loaders[r.type],this.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.KEY_LOAD_ERROR,fatal:!1,frag:r,response:e})},t.loadtimeout=function(e,t){var r=t.frag,i=r.loader;i&&i.abort(),delete this.loaders[r.type],this.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.KEY_LOAD_TIMEOUT,fatal:!1,frag:r})},e}();function M(e,t){var r;try{r=new Event("addtrack")}catch(e){(r=document.createEvent("Event")).initEvent("addtrack",!1,!1)}r.track=e,t.dispatchEvent(r)}var N=r(181),U=r(856);function B(){return self.WebKitDataCue||self.VTTCue||self.TextTrackCue}var G=function(){var e=B();try{new e(0,Number.POSITIVE_INFINITY,"")}catch(e){return Number.MAX_VALUE}return Number.POSITIVE_INFINITY}();function V(e,t){return e.getTime()/1e3-t}const H=function(){function e(e){this.hls=void 0,this.id3Track=null,this.media=null,this.dateRangeCuesAppended={},this.hls=e,this._registerListeners()}var t=e.prototype;return t.destroy=function(){this._unregisterListeners(),this.id3Track=null,this.media=null,this.dateRangeCuesAppended={},this.hls=null},t._registerListeners=function(){var e=this.hls;e.on(s.Events.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(s.Events.MANIFEST_LOADING,this.onManifestLoading,this),e.on(s.Events.FRAG_PARSING_METADATA,this.onFragParsingMetadata,this),e.on(s.Events.BUFFER_FLUSHING,this.onBufferFlushing,this),e.on(s.Events.LEVEL_UPDATED,this.onLevelUpdated,this)},t._unregisterListeners=function(){var e=this.hls;e.off(s.Events.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(s.Events.MANIFEST_LOADING,this.onManifestLoading,this),e.off(s.Events.FRAG_PARSING_METADATA,this.onFragParsingMetadata,this),e.off(s.Events.BUFFER_FLUSHING,this.onBufferFlushing,this),e.off(s.Events.LEVEL_UPDATED,this.onLevelUpdated,this)},t.onMediaAttached=function(e,t){this.media=t.media},t.onMediaDetaching=function(){this.id3Track&&(function(e){var t=e.mode;if("disabled"===t&&(e.mode="hidden"),e.cues)for(var r=e.cues.length;r--;)e.removeCue(e.cues[r]);"disabled"===t&&(e.mode=t)}(this.id3Track),this.id3Track=null,this.media=null,this.dateRangeCuesAppended={})},t.onManifestLoading=function(){this.dateRangeCuesAppended={}},t.createTrack=function(e){var t=this.getID3Track(e.textTracks);return t.mode="hidden",t},t.getID3Track=function(e){if(this.media){for(var t=0;t<e.length;t++){var r=e[t];if("metadata"===r.kind&&"id3"===r.label)return M(r,this.media),r}return this.media.addTextTrack("metadata","id3")}},t.onFragParsingMetadata=function(e,t){if(this.media){var r=this.hls.config,i=r.enableEmsgMetadataCues,n=r.enableID3MetadataCues;if(i||n){t.frag;var a=t.samples;t.details,this.id3Track||(this.id3Track=this.createTrack(this.media));for(var s=B(),o=0;o<a.length;o++){var l=a[o].type;if((l!==U.MetadataSchema.emsg||i)&&n){var u=N.getID3Frames(a[o].data);if(u){var d=a[o].pts,h=d+a[o].duration;h>G&&(h=G),h-d<=0&&(h=d+.25);for(var f=0;f<u.length;f++){var c=u[f];if(!N.isTimeStampFrame(c)){this.updateId3CueEnds(d);var v=new s(d,h,"");v.value=c,l&&(v.type=l),this.id3Track.addCue(v)}}}}}}}},t.updateId3CueEnds=function(e){var t,r=null===(t=this.id3Track)||void 0===t?void 0:t.cues;if(r)for(var i=r.length;i--;){var n=r[i];n.startTime<e&&n.endTime===G&&(n.endTime=e)}},t.onBufferFlushing=function(e,t){var r=t.startOffset,i=t.endOffset,n=t.type,a=this.id3Track,s=this.hls;if(s){var o=s.config,l=o.enableEmsgMetadataCues,u=o.enableID3MetadataCues;a&&(l||u)&&function(e,t,r,i){var n=e.mode;if("disabled"===n&&(e.mode="hidden"),e.cues&&e.cues.length>0)for(var a=function(e,t,r){var i=[],n=function(e,t){if(t<e[0].startTime)return 0;var r=e.length-1;if(t>e[r].endTime)return-1;for(var i=0,n=r;i<=n;){var a=Math.floor((n+i)/2);if(t<e[a].startTime)n=a-1;else{if(!(t>e[a].startTime&&i<r))return a;i=a+1}}return e[i].startTime-t<t-e[n].startTime?i:n}(e,t);if(n>-1)for(var a=n,s=e.length;a<s;a++){var o=e[a];if(o.startTime>=t&&o.endTime<=r)i.push(o);else if(o.startTime>r)return i}return i}(e.cues,t,r),s=0;s<a.length;s++)i&&!i(a[s])||e.removeCue(a[s]);"disabled"===n&&(e.mode=n)}(a,r,i,"audio"===n?function(e){return e.type===U.MetadataSchema.audioId3&&u}:"video"===n?function(e){return e.type===U.MetadataSchema.emsg&&l}:function(e){return e.type===U.MetadataSchema.audioId3&&u||e.type===U.MetadataSchema.emsg&&l})}},t.onLevelUpdated=function(e,t){var r=this,n=t.details;if(this.media&&n.hasProgramDateTime&&this.hls.config.enableDateRangeMetadataCues){var s=this.dateRangeCuesAppended,o=this.id3Track,l=n.dateRanges,u=Object.keys(l);if(o)for(var d=Object.keys(s).filter((function(e){return!u.includes(e)})),h=function(e){var t=d[e];Object.keys(s[t].cues).forEach((function(e){o.removeCue(s[t].cues[e])})),delete s[t]},f=d.length;f--;)h(f);var c=n.fragments[n.fragments.length-1];if(0!==u.length&&(0,a.isFiniteNumber)(null==c?void 0:c.programDateTime)){this.id3Track||(this.id3Track=this.createTrack(this.media));for(var v=c.programDateTime/1e3-c.start,g=B(),p=function(e){var t=u[e],n=l[t],a=s[t],o=(null==a?void 0:a.cues)||{},d=(null==a?void 0:a.durationKnown)||!1,h=V(n.startDate,v),f=G,c=n.endDate;if(c)f=V(c,v),d=!0;else if(n.endOnNext&&!d){var p=u.reduce((function(e,t){var r=l[t];return r.class===n.class&&r.id!==t&&r.startDate>n.startDate&&e.push(r),e}),[]).sort((function(e,t){return e.startDate.getTime()-t.startDate.getTime()}))[0];p&&(f=V(p.startDate,v),d=!0)}for(var m,y=Object.keys(n.attr),E=0;E<y.length;E++){var T=y[E];if(T!==i.ID&&T!==i.CLASS&&T!==i.START_DATE&&T!==i.DURATION&&T!==i.END_DATE&&T!==i.END_ON_NEXT){var S=o[T];if(S)d&&!a.durationKnown&&(S.endTime=f);else{var b=n.attr[T];S=new g(h,f,""),T!==i.SCTE35_OUT&&T!==i.SCTE35_IN||(m=b,b=Uint8Array.from(m.replace(/^0x/,"").replace(/([\da-fA-F]{2}) ?/g,"0x$1 ").replace(/ +$/,"").split(" ")).buffer),S.value={key:T,data:b},S.type=U.MetadataSchema.dateRange,r.id3Track.addCue(S),o[T]=S}}}s[t]={cues:o,dateRange:n,durationKnown:d}},m=0;m<u.length;m++)p(m)}}},e}();function j(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}var K,W=function(){function e(e){var t=this;this.hls=void 0,this.config=void 0,this.media=null,this.levelDetails=null,this.currentTime=0,this.stallCount=0,this._latency=null,this.timeupdateHandler=function(){return t.timeupdate()},this.hls=e,this.config=e.config,this.registerListeners()}var t,r,i=e.prototype;return i.destroy=function(){this.unregisterListeners(),this.onMediaDetaching(),this.levelDetails=null,this.hls=this.timeupdateHandler=null},i.registerListeners=function(){this.hls.on(s.Events.MEDIA_ATTACHED,this.onMediaAttached,this),this.hls.on(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this),this.hls.on(s.Events.MANIFEST_LOADING,this.onManifestLoading,this),this.hls.on(s.Events.LEVEL_UPDATED,this.onLevelUpdated,this),this.hls.on(s.Events.ERROR,this.onError,this)},i.unregisterListeners=function(){this.hls.off(s.Events.MEDIA_ATTACHED,this.onMediaAttached),this.hls.off(s.Events.MEDIA_DETACHING,this.onMediaDetaching),this.hls.off(s.Events.MANIFEST_LOADING,this.onManifestLoading),this.hls.off(s.Events.LEVEL_UPDATED,this.onLevelUpdated),this.hls.off(s.Events.ERROR,this.onError)},i.onMediaAttached=function(e,t){this.media=t.media,this.media.addEventListener("timeupdate",this.timeupdateHandler)},i.onMediaDetaching=function(){this.media&&(this.media.removeEventListener("timeupdate",this.timeupdateHandler),this.media=null)},i.onManifestLoading=function(){this.levelDetails=null,this._latency=null,this.stallCount=0},i.onLevelUpdated=function(e,t){var r=t.details;this.levelDetails=r,r.advanced&&this.timeupdate(),!r.live&&this.media&&this.media.removeEventListener("timeupdate",this.timeupdateHandler)},i.onError=function(e,t){t.details===o.ErrorDetails.BUFFER_STALLED_ERROR&&(this.stallCount++,l.logger.warn("[playback-rate-controller]: Stall detected, adjusting target latency"))},i.timeupdate=function(){var e=this.media,t=this.levelDetails;if(e&&t){this.currentTime=e.currentTime;var r=this.computeLatency();if(null!==r){this._latency=r;var i=this.config,n=i.lowLatencyMode,a=i.maxLiveSyncPlaybackRate;if(n&&1!==a){var s=this.targetLatency;if(null!==s){var o=r-s,l=o<Math.min(this.maxLatency,s+t.targetduration);if(t.live&&l&&o>.05&&this.forwardBufferLength>1){var u=Math.min(2,Math.max(1,a)),d=Math.round(2/(1+Math.exp(-.75*o-this.edgeStalled))*20)/20;e.playbackRate=Math.min(u,Math.max(1,d))}else 1!==e.playbackRate&&0!==e.playbackRate&&(e.playbackRate=1)}}}}},i.estimateLiveEdge=function(){var e=this.levelDetails;return null===e?null:e.edge+e.age},i.computeLatency=function(){var e=this.estimateLiveEdge();return null===e?null:e-this.currentTime},t=e,(r=[{key:"latency",get:function(){return this._latency||0}},{key:"maxLatency",get:function(){var e=this.config,t=this.levelDetails;return void 0!==e.liveMaxLatencyDuration?e.liveMaxLatencyDuration:t?e.liveMaxLatencyDurationCount*t.targetduration:0}},{key:"targetLatency",get:function(){var e=this.levelDetails;if(null===e)return null;var t=e.holdBack,r=e.partHoldBack,i=e.targetduration,n=this.config,a=n.liveSyncDuration,s=n.liveSyncDurationCount,o=n.lowLatencyMode,l=this.hls.userConfig,u=o&&r||t;(l.liveSyncDuration||l.liveSyncDurationCount||0===u)&&(u=void 0!==a?a:s*i);var d=i;return u+Math.min(1*this.stallCount,d)}},{key:"liveSyncPosition",get:function(){var e=this.estimateLiveEdge(),t=this.targetLatency,r=this.levelDetails;if(null===e||null===t||null===r)return null;var i=r.edge,n=e-t-this.edgeStalled,a=i-r.totalduration,s=i-(this.config.lowLatencyMode&&r.partTarget||r.targetduration);return Math.min(Math.max(a,n),s)}},{key:"drift",get:function(){var e=this.levelDetails;return null===e?1:e.drift}},{key:"edgeStalled",get:function(){var e=this.levelDetails;if(null===e)return 0;var t=3*(this.config.lowLatencyMode&&e.partTarget||e.targetduration);return Math.max(e.age-t,0)}},{key:"forwardBufferLength",get:function(){var e=this.media,t=this.levelDetails;if(!e||!t)return 0;var r=e.buffered.length;return(r?e.buffered.end(r-1):t.edge)-this.currentTime}}])&&j(t.prototype,r),Object.defineProperty(t,"prototype",{writable:!1}),e}();function Y(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}!function(e){e.No="",e.Yes="YES",e.v2="v2"}(K||(K={}));var q=function(){function e(e,t,r){this.msn=void 0,this.part=void 0,this.skip=void 0,this.msn=e,this.part=t,this.skip=r}return e.prototype.addDirectives=function(e){var t=new self.URL(e);return void 0!==this.msn&&t.searchParams.set("_HLS_msn",this.msn.toString()),void 0!==this.part&&t.searchParams.set("_HLS_part",this.part.toString()),this.skip&&t.searchParams.set("_HLS_skip",this.skip),t.toString()},e}(),z=function(){function e(e){this.attrs=void 0,this.audioCodec=void 0,this.bitrate=void 0,this.codecSet=void 0,this.height=void 0,this.id=void 0,this.name=void 0,this.videoCodec=void 0,this.width=void 0,this.unknownCodecs=void 0,this.audioGroupIds=void 0,this.details=void 0,this.fragmentError=0,this.loadError=0,this.loaded=void 0,this.realBitrate=0,this.textGroupIds=void 0,this.url=void 0,this._urlId=0,this.url=[e.url],this.attrs=e.attrs,this.bitrate=e.bitrate,e.details&&(this.details=e.details),this.id=e.id||0,this.name=e.name,this.width=e.width||0,this.height=e.height||0,this.audioCodec=e.audioCodec,this.videoCodec=e.videoCodec,this.unknownCodecs=e.unknownCodecs,this.codecSet=[e.videoCodec,e.audioCodec].filter((function(e){return e})).join(",").replace(/\.[^.,]+/g,"")}var t,r;return t=e,(r=[{key:"maxBitrate",get:function(){return Math.max(this.realBitrate,this.bitrate)}},{key:"uri",get:function(){return this.url[this._urlId]||""}},{key:"urlId",get:function(){return this._urlId},set:function(e){var t=e%this.url.length;this._urlId!==t&&(this.details=void 0,this._urlId=t)}}])&&Y(t.prototype,r),Object.defineProperty(t,"prototype",{writable:!1}),e}();function X(){return X=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},X.apply(this,arguments)}function Q(e,t,r){switch(t){case"audio":e.audioGroupIds||(e.audioGroupIds=[]),e.audioGroupIds.push(r);break;case"text":e.textGroupIds||(e.textGroupIds=[]),e.textGroupIds.push(r)}}function $(e){var t={};e.forEach((function(e){var r=e.groupId||"";e.id=t[r]=t[r]||0,t[r]++}))}function Z(e,t){var r=t.startPTS;if((0,a.isFiniteNumber)(r)){var i,n=0;t.sn>e.sn?(n=r-e.start,i=e):(n=e.start-r,i=t),i.duration!==n&&(i.duration=n)}else t.sn>e.sn?e.cc===t.cc&&e.minEndPTS?t.start=e.start+(e.minEndPTS-e.start):t.start=e.start+e.duration:t.start=Math.max(e.start-t.duration,0)}function J(e,t,r,i,n,s){i-r<=0&&(l.logger.warn("Fragment should have a positive duration",t),i=r+t.duration,s=n+t.duration);var o=r,u=i,d=t.startPTS,h=t.endPTS;if((0,a.isFiniteNumber)(d)){var f=Math.abs(d-r);(0,a.isFiniteNumber)(t.deltaPTS)?t.deltaPTS=Math.max(f,t.deltaPTS):t.deltaPTS=f,o=Math.max(r,d),r=Math.min(r,d),n=Math.min(n,t.startDTS),u=Math.min(i,h),i=Math.max(i,h),s=Math.max(s,t.endDTS)}t.duration=i-r;var c=r-t.start;t.appendedPTS=i,t.start=t.startPTS=r,t.maxStartPTS=o,t.startDTS=n,t.endPTS=i,t.minEndPTS=u,t.endDTS=s;var v,g=t.sn;if(!e||g<e.startSN||g>e.endSN)return 0;var p=g-e.startSN,m=e.fragments;for(m[p]=t,v=p;v>0;v--)Z(m[v],m[v-1]);for(v=p;v<m.length-1;v++)Z(m[v],m[v+1]);return e.fragmentHint&&Z(m[m.length-1],e.fragmentHint),e.PTSKnown=e.alignedSliding=!0,c}function ee(e,t){var r=t.startSN+t.skippedSegments-e.startSN,i=e.fragments;r<0||r>=i.length||function(e,t){if(t){for(var r=e.fragments,i=e.skippedSegments;i<r.length;i++)r[i].start+=t;e.fragmentHint&&(e.fragmentHint.start+=t)}}(t,i[r].start)}var te=function(){function e(e,t){this.hls=void 0,this.timer=-1,this.canLoad=!1,this.retryCount=0,this.log=void 0,this.warn=void 0,this.log=l.logger.log.bind(l.logger,t+":"),this.warn=l.logger.warn.bind(l.logger,t+":"),this.hls=e}var t=e.prototype;return t.destroy=function(){this.clearTimer(),this.hls=this.log=this.warn=null},t.onError=function(e,t){t.fatal&&t.type===o.ErrorTypes.NETWORK_ERROR&&this.clearTimer()},t.clearTimer=function(){clearTimeout(this.timer),this.timer=-1},t.startLoad=function(){this.canLoad=!0,this.retryCount=0,this.loadPlaylist()},t.stopLoad=function(){this.canLoad=!1,this.clearTimer()},t.switchParams=function(e,t){var r=null==t?void 0:t.renditionReports;if(r)for(var i=0;i<r.length;i++){var n=r[i],s=""+n.URI;if(s===e.slice(-s.length)){var o=parseInt(n["LAST-MSN"]),l=parseInt(n["LAST-PART"]);if(t&&this.hls.config.lowLatencyMode){var u=Math.min(t.age-t.partTarget,t.targetduration);void 0!==l&&u>t.partTarget&&(l+=1)}if((0,a.isFiniteNumber)(o))return new q(o,(0,a.isFiniteNumber)(l)?l:void 0,K.No)}}},t.loadPlaylist=function(e){},t.shouldLoadTrack=function(e){return this.canLoad&&e&&!!e.url&&(!e.details||e.details.live)},t.playlistLoaded=function(e,t,r){var i=this,n=t.details,s=t.stats,o=s.loading.end?Math.max(0,self.performance.now()-s.loading.end):0;if(n.advancedDateTime=Date.now()-o,n.live||null!=r&&r.live){if(n.reloaded(r),r&&this.log("live playlist "+e+" "+(n.advanced?"REFRESHED "+n.lastPartSn+"-"+n.lastPartIndex:"MISSED")),r&&n.fragments.length>0&&function(e,t){for(var r=null,i=e.fragments,n=i.length-1;n>=0;n--){var s=i[n].initSegment;if(s){r=s;break}}e.fragmentHint&&delete e.fragmentHint.endPTS;var o,u,d,h,f,c=0;if(function(e,t,r){for(var i=t.skippedSegments,n=Math.max(e.startSN,t.startSN)-t.startSN,a=(e.fragmentHint?1:0)+(i?t.endSN:Math.min(e.endSN,t.endSN))-t.startSN,s=t.startSN-e.startSN,o=t.fragmentHint?t.fragments.concat(t.fragmentHint):t.fragments,l=e.fragmentHint?e.fragments.concat(e.fragmentHint):e.fragments,u=n;u<=a;u++){var d=l[s+u],h=o[u];i&&!h&&u<i&&(h=t.fragments[u]=d),d&&h&&r(d,h)}}(e,t,(function(e,i){e.relurl&&(c=e.cc-i.cc),(0,a.isFiniteNumber)(e.startPTS)&&(0,a.isFiniteNumber)(e.endPTS)&&(i.start=i.startPTS=e.startPTS,i.startDTS=e.startDTS,i.appendedPTS=e.appendedPTS,i.maxStartPTS=e.maxStartPTS,i.endPTS=e.endPTS,i.endDTS=e.endDTS,i.minEndPTS=e.minEndPTS,i.duration=e.endPTS-e.startPTS,i.duration&&(o=i),t.PTSKnown=t.alignedSliding=!0),i.elementaryStreams=e.elementaryStreams,i.loader=e.loader,i.stats=e.stats,i.urlId=e.urlId,e.initSegment&&(i.initSegment=e.initSegment,r=e.initSegment)})),r&&(t.fragmentHint?t.fragments.concat(t.fragmentHint):t.fragments).forEach((function(e){var t;e.initSegment&&e.initSegment.relurl!==(null===(t=r)||void 0===t?void 0:t.relurl)||(e.initSegment=r)})),t.skippedSegments)if(t.deltaUpdateFailed=t.fragments.some((function(e){return!e})),t.deltaUpdateFailed){l.logger.warn("[level-helper] Previous playlist missing segments skipped in delta playlist");for(var v=t.skippedSegments;v--;)t.fragments.shift();t.startSN=t.fragments[0].sn,t.startCC=t.fragments[0].cc}else t.canSkipDateRanges&&(t.dateRanges=(u=e.dateRanges,d=t.dateRanges,h=t.recentlyRemovedDateranges,f=X({},u),h&&h.forEach((function(e){delete f[e]})),Object.keys(d).forEach((function(e){var t=new g(d[e].attr,f[e]);t.isValid?f[e]=t:l.logger.warn('Ignoring invalid Playlist Delta Update DATERANGE tag: "'+JSON.stringify(d[e].attr)+'"')})),f));var p=t.fragments;if(c){l.logger.warn("discontinuity sliding from playlist, take drift into account");for(var m=0;m<p.length;m++)p[m].cc+=c}t.skippedSegments&&(t.startCC=t.fragments[0].cc),function(e,t,r){if(e&&t)for(var i=0,n=0,a=e.length;n<=a;n++){var s=e[n],o=t[n+i];s&&o&&s.index===o.index&&s.fragment.sn===o.fragment.sn?(l=s,(u=o).elementaryStreams=l.elementaryStreams,u.stats=l.stats):i--}var l,u}(e.partList,t.partList),o?J(t,o,o.startPTS,o.endPTS,o.startDTS,o.endDTS):ee(e,t),p.length&&(t.totalduration=t.edge-p[0].start),t.driftStartTime=e.driftStartTime,t.driftStart=e.driftStart;var y=t.advancedDateTime;if(t.advanced&&y){var E=t.edge;t.driftStart||(t.driftStartTime=y,t.driftStart=E),t.driftEndTime=y,t.driftEnd=E}else t.driftEndTime=e.driftEndTime,t.driftEnd=e.driftEnd,t.advancedDateTime=e.advancedDateTime}(r,n),!this.canLoad||!n.live)return;var u,d=void 0,h=void 0;if(n.canBlockReload&&n.endSN&&n.advanced){var f=this.hls.config.lowLatencyMode,c=n.lastPartSn,v=n.endSN,p=n.lastPartIndex,m=c===v;-1!==p?(d=m?v+1:c,h=m?f?0:p:p+1):d=v+1;var y=n.age,E=y+n.ageHeader,T=Math.min(E-n.partTarget,1.5*n.targetduration);if(T>0){if(r&&T>r.tuneInGoal)this.warn("CDN Tune-in goal increased from: "+r.tuneInGoal+" to: "+T+" with playlist age: "+n.age),T=0;else{var S=Math.floor(T/n.targetduration);d+=S,void 0!==h&&(h+=Math.round(T%n.targetduration/n.partTarget)),this.log("CDN Tune-in age: "+n.ageHeader+"s last advanced "+y.toFixed(2)+"s goal: "+T+" skip sn "+S+" to part "+h)}n.tuneInGoal=T}if(u=this.getDeliveryDirectives(n,t.deliveryDirectives,d,h),f||!m)return void this.loadPlaylist(u)}else u=this.getDeliveryDirectives(n,t.deliveryDirectives,d,h);var b=function(e,t){var r,i=1e3*e.levelTargetDuration,n=i/2,a=e.age,s=a>0&&a<3*i,o=t.loading.end-t.loading.start,l=e.availabilityDelay;if(!1===e.updated)if(s){var u=333*e.misses;r=Math.max(Math.min(n,2*o),u),e.availabilityDelay=(e.availabilityDelay||0)+r}else r=n;else s?(l=Math.min(l||i/2,a),e.availabilityDelay=l,r=l+i-a):r=i-o;return Math.round(r)}(n,s);void 0!==d&&n.canBlockReload&&(b-=n.partTarget||1),this.log("reload live playlist "+e+" in "+Math.round(b)+" ms"),this.timer=self.setTimeout((function(){return i.loadPlaylist(u)}),b)}else this.clearTimer()},t.getDeliveryDirectives=function(e,t,r,i){var n=function(e,t){var r=e.canSkipUntil,i=e.canSkipDateRanges,n=e.endSN;return r&&(void 0!==t?t-n:0)<r?i?K.v2:K.Yes:K.No}(e,r);return null!=t&&t.skip&&e.deltaUpdateFailed&&(r=t.msn,i=t.part,n=K.No),new q(r,i,n)},t.retryLoadingOrFail=function(e){var t,r=this,i=this.hls.config,n=this.retryCount<i.levelLoadingMaxRetry;if(n)if(this.retryCount++,e.details.indexOf("LoadTimeOut")>-1&&null!==(t=e.context)&&void 0!==t&&t.deliveryDirectives)this.warn("retry playlist loading #"+this.retryCount+' after "'+e.details+'"'),this.loadPlaylist();else{var a=Math.min(Math.pow(2,this.retryCount)*i.levelLoadingRetryDelay,i.levelLoadingMaxRetryTimeout);this.timer=self.setTimeout((function(){return r.loadPlaylist()}),a),this.warn("retry playlist loading #"+this.retryCount+" in "+a+' ms after "'+e.details+'"')}else this.warn('cannot recover from error "'+e.details+'"'),this.clearTimer(),e.fatal=!0;return n},e}();function re(){return re=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},re.apply(this,arguments)}function ie(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}function ne(e,t){return ne=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},ne(e,t)}var ae,se=/chrome|firefox/.test(navigator.userAgent.toLowerCase()),oe=function(e){var t,r;function i(t){var r;return(r=e.call(this,t,"[level-controller]")||this)._levels=[],r._firstLevel=-1,r._startLevel=void 0,r.currentLevelIndex=-1,r.manualLevelIndex=-1,r.onParsedComplete=void 0,r._registerListeners(),r}r=e,(t=i).prototype=Object.create(r.prototype),t.prototype.constructor=t,ne(t,r);var n,a,l=i.prototype;return l._registerListeners=function(){var e=this.hls;e.on(s.Events.MANIFEST_LOADED,this.onManifestLoaded,this),e.on(s.Events.LEVEL_LOADED,this.onLevelLoaded,this),e.on(s.Events.AUDIO_TRACK_SWITCHED,this.onAudioTrackSwitched,this),e.on(s.Events.FRAG_LOADED,this.onFragLoaded,this),e.on(s.Events.ERROR,this.onError,this)},l._unregisterListeners=function(){var e=this.hls;e.off(s.Events.MANIFEST_LOADED,this.onManifestLoaded,this),e.off(s.Events.LEVEL_LOADED,this.onLevelLoaded,this),e.off(s.Events.AUDIO_TRACK_SWITCHED,this.onAudioTrackSwitched,this),e.off(s.Events.FRAG_LOADED,this.onFragLoaded,this),e.off(s.Events.ERROR,this.onError,this)},l.destroy=function(){this._unregisterListeners(),this.manualLevelIndex=-1,this._levels.length=0,e.prototype.destroy.call(this)},l.startLoad=function(){this._levels.forEach((function(e){e.loadError=0})),e.prototype.startLoad.call(this)},l.onManifestLoaded=function(e,t){var r,i,n=[],a=[],l=[],u={},d=!1,h=!1,f=!1;if(t.levels.forEach((function(e){var t=e.attrs;d=d||!(!e.width||!e.height),h=h||!!e.videoCodec,f=f||!!e.audioCodec,se&&e.audioCodec&&-1!==e.audioCodec.indexOf("mp4a.40.34")&&(e.audioCodec=void 0);var r=e.bitrate+"-"+e.attrs.RESOLUTION+"-"+e.attrs.CODECS;(i=u[r])?i.url.push(e.url):(i=new z(e),u[r]=i,n.push(i)),t&&(t.AUDIO&&Q(i,"audio",t.AUDIO),t.SUBTITLES&&Q(i,"text",t.SUBTITLES))})),(d||h)&&f&&(n=n.filter((function(e){var t=e.videoCodec,r=e.width,i=e.height;return!!t||!(!r||!i)}))),n=n.filter((function(e){var t=e.audioCodec,r=e.videoCodec;return(!t||S(t,"audio"))&&(!r||S(r,"video"))})),t.audioTracks&&$(a=t.audioTracks.filter((function(e){return!e.audioCodec||S(e.audioCodec,"audio")}))),t.subtitles&&$(l=t.subtitles),n.length>0){r=n[0].bitrate,n.sort((function(e,t){return e.bitrate-t.bitrate})),this._levels=n;for(var c=0;c<n.length;c++)if(n[c].bitrate===r){this._firstLevel=c,this.log("manifest loaded, "+n.length+" level(s) found, first bitrate: "+r);break}var v=f&&!h,g={levels:n,audioTracks:a,subtitleTracks:l,firstLevel:this._firstLevel,stats:t.stats,audio:f,video:h,altAudio:!v&&a.some((function(e){return!!e.url}))};this.hls.trigger(s.Events.MANIFEST_PARSED,g),(this.hls.config.autoStartLoad||this.hls.forceStartLoad)&&this.hls.startLoad(this.hls.config.startPosition)}else this.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.MEDIA_ERROR,details:o.ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,fatal:!0,url:t.url,reason:"no level with compatible codecs found in manifest"})},l.onError=function(t,r){var i;if(e.prototype.onError.call(this,t,r),!r.fatal){var n=r.context,a=this._levels[this.currentLevelIndex];if(n&&(n.type===C.PlaylistContextType.AUDIO_TRACK&&a.audioGroupIds&&n.groupId===a.audioGroupIds[a.urlId]||n.type===C.PlaylistContextType.SUBTITLE_TRACK&&a.textGroupIds&&n.groupId===a.textGroupIds[a.urlId]))this.redundantFailover(this.currentLevelIndex);else{var s,l=!1,u=!0;switch(r.details){case o.ErrorDetails.FRAG_LOAD_ERROR:case o.ErrorDetails.FRAG_LOAD_TIMEOUT:case o.ErrorDetails.KEY_LOAD_ERROR:case o.ErrorDetails.KEY_LOAD_TIMEOUT:if(r.frag){var d=r.frag.type===C.PlaylistLevelType.MAIN?r.frag.level:this.currentLevelIndex,h=this._levels[d];h?(h.fragmentError++,h.fragmentError>this.hls.config.fragLoadingMaxRetry&&(s=d)):s=d}break;case o.ErrorDetails.LEVEL_LOAD_ERROR:case o.ErrorDetails.LEVEL_LOAD_TIMEOUT:n&&(n.deliveryDirectives&&(u=!1),s=n.level),l=!0;break;case o.ErrorDetails.REMUX_ALLOC_ERROR:s=null!=(i=r.level)?i:this.currentLevelIndex,l=!0}void 0!==s&&this.recoverLevel(r,s,l,u)}}},l.recoverLevel=function(e,t,r,i){var n=e.details,a=this._levels[t];if(a.loadError++,r){if(!this.retryLoadingOrFail(e))return void(this.currentLevelIndex=-1);e.levelRetry=!0}if(i){var s=a.url.length;if(s>1&&a.loadError<s)e.levelRetry=!0,this.redundantFailover(t);else if(-1===this.manualLevelIndex){for(var o=-1,l=this._levels,u=l.length;u--;){var d=(u+this.currentLevelIndex)%l.length;if(d!==this.currentLevelIndex&&0===l[d].loadError){o=d;break}}o>-1&&this.currentLevelIndex!==o&&(this.warn(n+": switch to "+o),e.levelRetry=!0,this.hls.nextAutoLevel=o)}}},l.redundantFailover=function(e){var t=this._levels[e],r=t.url.length;if(r>1){var i=(t.urlId+1)%r;this.warn("Switching to redundant URL-id "+i),this._levels.forEach((function(e){e.urlId=i})),this.level=e}},l.onFragLoaded=function(e,t){var r=t.frag;if(void 0!==r&&r.type===C.PlaylistLevelType.MAIN){var i=this._levels[r.level];void 0!==i&&(i.fragmentError=0,i.loadError=0)}},l.onLevelLoaded=function(e,t){var r,i,n=t.level,a=t.details,s=this._levels[n];if(!s)return this.warn("Invalid level index "+n),void(null!==(i=t.deliveryDirectives)&&void 0!==i&&i.skip&&(a.deltaUpdateFailed=!0));n===this.currentLevelIndex?(0===s.fragmentError&&(s.loadError=0,this.retryCount=0),this.playlistLoaded(n,t,s.details)):null!==(r=t.deliveryDirectives)&&void 0!==r&&r.skip&&(a.deltaUpdateFailed=!0)},l.onAudioTrackSwitched=function(e,t){var r=this.hls.levels[this.currentLevelIndex];if(r&&r.audioGroupIds){for(var i=-1,n=this.hls.audioTracks[t.id].groupId,a=0;a<r.audioGroupIds.length;a++)if(r.audioGroupIds[a]===n){i=a;break}i!==r.urlId&&(r.urlId=i,this.startLoad())}},l.loadPlaylist=function(e){var t=this.currentLevelIndex,r=this._levels[t];if(this.canLoad&&r&&r.url.length>0){var i=r.urlId,n=r.url[i];if(e)try{n=e.addDirectives(n)}catch(e){this.warn("Could not construct new URL with HLS Delivery Directives: "+e)}this.log("Attempt loading level index "+t+(e?" at sn "+e.msn+" part "+e.part:"")+" with URL-id "+i+" "+n),this.clearTimer(),this.hls.trigger(s.Events.LEVEL_LOADING,{url:n,level:t,id:i,deliveryDirectives:e||null})}},l.removeLevel=function(e,t){var r=function(e,r){return r!==t},i=this._levels.filter((function(i,n){return n!==e||i.url.length>1&&void 0!==t&&(i.url=i.url.filter(r),i.audioGroupIds&&(i.audioGroupIds=i.audioGroupIds.filter(r)),i.textGroupIds&&(i.textGroupIds=i.textGroupIds.filter(r)),i.urlId=0,!0)})).map((function(e,t){var r=e.details;return null!=r&&r.fragments&&r.fragments.forEach((function(e){e.level=t})),e}));this._levels=i,this.hls.trigger(s.Events.LEVELS_UPDATED,{levels:i})},n=i,(a=[{key:"levels",get:function(){return 0===this._levels.length?null:this._levels}},{key:"level",get:function(){return this.currentLevelIndex},set:function(e){var t,r=this._levels;if(0!==r.length&&(this.currentLevelIndex!==e||null===(t=r[e])||void 0===t||!t.details)){if(e<0||e>=r.length){var i=e<0;if(this.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.OTHER_ERROR,details:o.ErrorDetails.LEVEL_SWITCH_ERROR,level:e,fatal:i,reason:"invalid level idx"}),i)return;e=Math.min(e,r.length-1)}this.clearTimer();var n=this.currentLevelIndex,a=r[n],l=r[e];this.log("switching to level "+e+" from "+n),this.currentLevelIndex=e;var u=re({},l,{level:e,maxBitrate:l.maxBitrate,uri:l.uri,urlId:l.urlId});delete u._urlId,this.hls.trigger(s.Events.LEVEL_SWITCHING,u);var d=l.details;if(!d||d.live){var h=this.switchParams(l.uri,null==a?void 0:a.details);this.loadPlaylist(h)}}}},{key:"manualLevel",get:function(){return this.manualLevelIndex},set:function(e){this.manualLevelIndex=e,void 0===this._startLevel&&(this._startLevel=e),-1!==e&&(this.level=e)}},{key:"firstLevel",get:function(){return this._firstLevel},set:function(e){this._firstLevel=e}},{key:"startLevel",get:function(){if(void 0===this._startLevel){var e=this.hls.config.startLevel;return void 0!==e?e:this._firstLevel}return this._startLevel},set:function(e){this._startLevel=e}},{key:"nextLoadLevel",get:function(){return-1!==this.manualLevelIndex?this.manualLevelIndex:this.hls.nextAutoLevel},set:function(e){this.level=e,-1===this.manualLevelIndex&&(this.hls.nextAutoLevel=e)}}])&&ie(n.prototype,a),Object.defineProperty(n,"prototype",{writable:!1}),i}(te);!function(e){e.NOT_LOADED="NOT_LOADED",e.APPENDING="APPENDING",e.PARTIAL="PARTIAL",e.OK="OK"}(ae||(ae={}));var le=function(){function e(e){this.activeFragment=null,this.activeParts=null,this.fragments=Object.create(null),this.timeRanges=Object.create(null),this.bufferPadding=.2,this.hls=void 0,this.hls=e,this._registerListeners()}var t=e.prototype;return t._registerListeners=function(){var e=this.hls;e.on(s.Events.BUFFER_APPENDED,this.onBufferAppended,this),e.on(s.Events.FRAG_BUFFERED,this.onFragBuffered,this),e.on(s.Events.FRAG_LOADED,this.onFragLoaded,this)},t._unregisterListeners=function(){var e=this.hls;e.off(s.Events.BUFFER_APPENDED,this.onBufferAppended,this),e.off(s.Events.FRAG_BUFFERED,this.onFragBuffered,this),e.off(s.Events.FRAG_LOADED,this.onFragLoaded,this)},t.destroy=function(){this._unregisterListeners(),this.fragments=this.timeRanges=null},t.getAppendedFrag=function(e,t){if(t===C.PlaylistLevelType.MAIN){var r=this.activeFragment,i=this.activeParts;if(!r)return null;if(i)for(var n=i.length;n--;){var a=i[n],s=a?a.end:r.appendedPTS;if(a.start<=e&&void 0!==s&&e<=s)return n>9&&(this.activeParts=i.slice(n-9)),a}else if(r.start<=e&&void 0!==r.appendedPTS&&e<=r.appendedPTS)return r}return this.getBufferedFrag(e,t)},t.getBufferedFrag=function(e,t){for(var r=this.fragments,i=Object.keys(r),n=i.length;n--;){var a=r[i[n]];if((null==a?void 0:a.body.type)===t&&a.buffered){var s=a.body;if(s.start<=e&&e<=s.end)return s}}return null},t.detectEvictedFragments=function(e,t,r){var i=this;Object.keys(this.fragments).forEach((function(n){var a=i.fragments[n];if(a)if(a.buffered){var s=a.range[e];s&&s.time.some((function(e){var r=!i.isTimeBuffered(e.startPTS,e.endPTS,t);return r&&i.removeFragment(a.body),r}))}else a.body.type===r&&i.removeFragment(a.body)}))},t.detectPartialFragments=function(e){var t=this,r=this.timeRanges,i=e.frag,n=e.part;if(r&&"initSegment"!==i.sn){var a=de(i),s=this.fragments[a];s&&(Object.keys(r).forEach((function(e){var a=i.elementaryStreams[e];if(a){var o=r[e],l=null!==n||!0===a.partial;s.range[e]=t.getBufferedTimes(i,n,l,o)}})),s.loaded=null,Object.keys(s.range).length?s.buffered=!0:this.removeFragment(s.body))}},t.fragBuffered=function(e){var t=de(e),r=this.fragments[t];r&&(r.loaded=null,r.buffered=!0)},t.getBufferedTimes=function(e,t,r,i){for(var n={time:[],partial:r},a=t?t.start:e.start,s=t?t.end:e.end,o=e.minEndPTS||s,l=e.maxStartPTS||a,u=0;u<i.length;u++){var d=i.start(u)-this.bufferPadding,h=i.end(u)+this.bufferPadding;if(l>=d&&o<=h){n.time.push({startPTS:Math.max(a,i.start(u)),endPTS:Math.min(s,i.end(u))});break}if(a<h&&s>d)n.partial=!0,n.time.push({startPTS:Math.max(a,i.start(u)),endPTS:Math.min(s,i.end(u))});else if(s<=d)break}return n},t.getPartialFragment=function(e){var t,r,i,n=null,a=0,s=this.bufferPadding,o=this.fragments;return Object.keys(o).forEach((function(l){var u=o[l];u&&ue(u)&&(r=u.body.start-s,i=u.body.end+s,e>=r&&e<=i&&(t=Math.min(e-r,i-e),a<=t&&(n=u.body,a=t)))})),n},t.getState=function(e){var t=de(e),r=this.fragments[t];return r?r.buffered?ue(r)?ae.PARTIAL:ae.OK:ae.APPENDING:ae.NOT_LOADED},t.isTimeBuffered=function(e,t,r){for(var i,n,a=0;a<r.length;a++){if(i=r.start(a)-this.bufferPadding,n=r.end(a)+this.bufferPadding,e>=i&&t<=n)return!0;if(t<=i)return!1}return!1},t.onFragLoaded=function(e,t){var r=t.frag,i=t.part;if("initSegment"!==r.sn&&!r.bitrateTest&&!i){var n=de(r);this.fragments[n]={body:r,loaded:t,buffered:!1,range:Object.create(null)}}},t.onBufferAppended=function(e,t){var r=this,i=t.frag,n=t.part,a=t.timeRanges;if(i.type===C.PlaylistLevelType.MAIN)if(this.activeFragment=i,n){var s=this.activeParts;s||(this.activeParts=s=[]),s.push(n)}else this.activeParts=null;this.timeRanges=a,Object.keys(a).forEach((function(e){var t=a[e];if(r.detectEvictedFragments(e,t),!n)for(var s=0;s<t.length;s++)i.appendedPTS=Math.max(t.end(s),i.appendedPTS||0)}))},t.onFragBuffered=function(e,t){this.detectPartialFragments(t)},t.hasFragment=function(e){var t=de(e);return!!this.fragments[t]},t.removeFragmentsInRange=function(e,t,r){var i=this;Object.keys(this.fragments).forEach((function(n){var a=i.fragments[n];if(a&&a.buffered){var s=a.body;s.type===r&&s.start<t&&s.end>e&&i.removeFragment(s)}}))},t.removeFragment=function(e){var t=de(e);e.stats.loaded=0,e.clearElementaryStreamInfo(),delete this.fragments[t]},t.removeAllFragments=function(){this.fragments=Object.create(null),this.activeFragment=null,this.activeParts=null},e}();function ue(e){var t,r;return e.buffered&&((null===(t=e.range.video)||void 0===t?void 0:t.partial)||(null===(r=e.range.audio)||void 0===r?void 0:r.partial))}function de(e){return e.type+"_"+e.level+"_"+e.urlId+"_"+e.sn}var he=function(){function e(){this._boundTick=void 0,this._tickTimer=null,this._tickInterval=null,this._tickCallCount=0,this._boundTick=this.tick.bind(this)}var t=e.prototype;return t.destroy=function(){this.onHandlerDestroying(),this.onHandlerDestroyed()},t.onHandlerDestroying=function(){this.clearNextTick(),this.clearInterval()},t.onHandlerDestroyed=function(){},t.hasInterval=function(){return!!this._tickInterval},t.hasNextTick=function(){return!!this._tickTimer},t.setInterval=function(e){return!this._tickInterval&&(this._tickInterval=self.setInterval(this._boundTick,e),!0)},t.clearInterval=function(){return!!this._tickInterval&&(self.clearInterval(this._tickInterval),this._tickInterval=null,!0)},t.clearNextTick=function(){return!!this._tickTimer&&(self.clearTimeout(this._tickTimer),this._tickTimer=null,!0)},t.tick=function(){this._tickCallCount++,1===this._tickCallCount&&(this.doTick(),this._tickCallCount>1&&this.tickImmediate(),this._tickCallCount=0)},t.tickImmediate=function(){this.clearNextTick(),this._tickTimer=self.setTimeout(this._boundTick,0)},t.doTick=function(){},e}(),fe={length:0,start:function(){return 0},end:function(){return 0}},ce=function(){function e(){}return e.isBuffered=function(t,r){try{if(t)for(var i=e.getBuffered(t),n=0;n<i.length;n++)if(r>=i.start(n)&&r<=i.end(n))return!0}catch(e){}return!1},e.bufferInfo=function(t,r,i){try{if(t){var n,a=e.getBuffered(t),s=[];for(n=0;n<a.length;n++)s.push({start:a.start(n),end:a.end(n)});return this.bufferedInfo(s,r,i)}}catch(e){}return{len:0,start:r,end:r,nextStart:void 0}},e.bufferedInfo=function(e,t,r){t=Math.max(0,t),e.sort((function(e,t){return e.start-t.start||t.end-e.end}));var i=[];if(r)for(var n=0;n<e.length;n++){var a=i.length;if(a){var s=i[a-1].end;e[n].start-s<r?e[n].end>s&&(i[a-1].end=e[n].end):i.push(e[n])}else i.push(e[n])}else i=e;for(var o,l=0,u=t,d=t,h=0;h<i.length;h++){var f=i[h].start,c=i[h].end;if(t+r>=f&&t<c)u=f,l=(d=c)-t;else if(t+r<f){o=f;break}}return{len:l,start:u||0,end:d||0,nextStart:o}},e.getBuffered=function(e){try{return e.buffered}catch(e){return l.logger.log("failed to get media.buffered",e),fe}},e}(),ve=function(e,t,r,i,n,a){void 0===i&&(i=0),void 0===n&&(n=-1),void 0===a&&(a=!1),this.level=void 0,this.sn=void 0,this.part=void 0,this.id=void 0,this.size=void 0,this.partial=void 0,this.transmuxing={start:0,executeStart:0,executeEnd:0,end:0},this.buffering={audio:{start:0,executeStart:0,executeEnd:0,end:0},video:{start:0,executeStart:0,executeEnd:0,end:0},audiovideo:{start:0,executeStart:0,executeEnd:0,end:0}},this.level=e,this.sn=t,this.id=r,this.size=i,this.part=n,this.partial=a};function ge(e,t){if(e){var r=e.start+t;e.start=e.startPTS=r,e.endPTS=r+e.duration}}function pe(e,t){for(var r=t.fragments,i=0,n=r.length;i<n;i++)ge(r[i],e);t.fragmentHint&&ge(t.fragmentHint,e),t.alignedSliding=!0}const me=function(e,t){for(var r=0,i=e.length-1,n=null,a=null;r<=i;){var s=t(a=e[n=(r+i)/2|0]);if(s>0)r=n+1;else{if(!(s<0))return a;i=n-1}}return null};function ye(e,t,r){void 0===e&&(e=0),void 0===t&&(t=0);var i=Math.min(t,r.duration+(r.deltaPTS?r.deltaPTS:0));return r.start+r.duration-i<=e?1:r.start-i>e&&r.start?-1:0}function Ee(e,t,r){var i=1e3*Math.min(t,r.duration+(r.deltaPTS?r.deltaPTS:0));return(r.endProgramDateTime||0)-i>e}function Te(e){var t="function"==typeof Map?new Map:void 0;return Te=function(e){if(null===e||(r=e,-1===Function.toString.call(r).indexOf("[native code]")))return e;var r;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,i)}function i(){return Se(e,arguments,Ae(this).constructor)}return i.prototype=Object.create(e.prototype,{constructor:{value:i,enumerable:!1,writable:!0,configurable:!0}}),Le(i,e)},Te(e)}function Se(e,t,r){return Se=be()?Reflect.construct.bind():function(e,t,r){var i=[null];i.push.apply(i,t);var n=new(Function.bind.apply(e,i));return r&&Le(n,r.prototype),n},Se.apply(null,arguments)}function be(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(e){return!1}}function Le(e,t){return Le=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},Le(e,t)}function Ae(e){return Ae=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},Ae(e)}var De=Math.pow(2,17),Re=function(){function e(e){this.config=void 0,this.loader=null,this.partLoadTimeout=-1,this.config=e}var t=e.prototype;return t.destroy=function(){this.loader&&(this.loader.destroy(),this.loader=null)},t.abort=function(){this.loader&&this.loader.abort()},t.load=function(e,t){var r=this,i=e.url;if(!i)return Promise.reject(new _e({type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.FRAG_LOAD_ERROR,fatal:!1,frag:e,networkDetails:null},"Fragment does not have a "+(i?"part list":"url")));this.abort();var n=this.config,a=n.fLoader,s=n.loader;return new Promise((function(i,l){r.loader&&r.loader.destroy();var u=r.loader=e.loader=a?new a(n):new s(n),d=ke(e),h={timeout:n.fragLoadingTimeOut,maxRetry:0,retryDelay:0,maxRetryDelay:n.fragLoadingMaxRetryTimeout,highWaterMark:"initSegment"===e.sn?1/0:De};e.stats=u.stats,u.load(d,h,{onSuccess:function(t,n,a,s){r.resetLoader(e,u),i({frag:e,part:null,payload:t.data,networkDetails:s})},onError:function(t,i,n){r.resetLoader(e,u),l(new _e({type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.FRAG_LOAD_ERROR,fatal:!1,frag:e,response:t,networkDetails:n}))},onAbort:function(t,i,n){r.resetLoader(e,u),l(new _e({type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.INTERNAL_ABORTED,fatal:!1,frag:e,networkDetails:n}))},onTimeout:function(t,i,n){r.resetLoader(e,u),l(new _e({type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.FRAG_LOAD_TIMEOUT,fatal:!1,frag:e,networkDetails:n}))},onProgress:function(r,i,n,a){t&&t({frag:e,part:null,payload:n,networkDetails:a})}})}))},t.loadPart=function(e,t,r){var i=this;this.abort();var n=this.config,a=n.fLoader,s=n.loader;return new Promise((function(l,u){i.loader&&i.loader.destroy();var d=i.loader=e.loader=a?new a(n):new s(n),h=ke(e,t),f={timeout:n.fragLoadingTimeOut,maxRetry:0,retryDelay:0,maxRetryDelay:n.fragLoadingMaxRetryTimeout,highWaterMark:De};t.stats=d.stats,d.load(h,f,{onSuccess:function(n,a,s,o){i.resetLoader(e,d),i.updateStatsFromPart(e,t);var u={frag:e,part:t,payload:n.data,networkDetails:o};r(u),l(u)},onError:function(r,n,a){i.resetLoader(e,d),u(new _e({type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.FRAG_LOAD_ERROR,fatal:!1,frag:e,part:t,response:r,networkDetails:a}))},onAbort:function(r,n,a){e.stats.aborted=t.stats.aborted,i.resetLoader(e,d),u(new _e({type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.INTERNAL_ABORTED,fatal:!1,frag:e,part:t,networkDetails:a}))},onTimeout:function(r,n,a){i.resetLoader(e,d),u(new _e({type:o.ErrorTypes.NETWORK_ERROR,details:o.ErrorDetails.FRAG_LOAD_TIMEOUT,fatal:!1,frag:e,part:t,networkDetails:a}))}})}))},t.updateStatsFromPart=function(e,t){var r=e.stats,i=t.stats,n=i.total;if(r.loaded+=i.loaded,n){var a=Math.round(e.duration/t.duration),s=Math.min(Math.round(r.loaded/n),a),o=(a-s)*Math.round(r.loaded/s);r.total=r.loaded+o}else r.total=Math.max(r.loaded,r.total);var l=r.loading,u=i.loading;l.start?l.first+=u.first-u.start:(l.start=u.start,l.first=u.first),l.end=u.end},t.resetLoader=function(e,t){e.loader=null,this.loader===t&&(self.clearTimeout(this.partLoadTimeout),this.loader=null),t.destroy()},e}();function ke(e,t){void 0===t&&(t=null);var r=t||e,i={frag:e,part:t,responseType:"arraybuffer",url:r.url,headers:{},rangeStart:0,rangeEnd:0},n=r.byteRangeStartOffset,s=r.byteRangeEndOffset;return(0,a.isFiniteNumber)(n)&&(0,a.isFiniteNumber)(s)&&(i.rangeStart=n,i.rangeEnd=s),i}var _e=function(e){var t,r;function i(t){for(var r,i=arguments.length,n=new Array(i>1?i-1:0),a=1;a<i;a++)n[a-1]=arguments[a];return(r=e.call.apply(e,[this].concat(n))||this).data=void 0,r.data=t,r}return r=e,(t=i).prototype=Object.create(r.prototype),t.prototype.constructor=t,Le(t,r),i}(Te(Error)),xe=r(21);const Ie=function(e){for(var t="",r=e.length,i=0;i<r;i++)t+="["+e.start(i).toFixed(3)+","+e.end(i).toFixed(3)+"]";return t};function we(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}function Ce(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e}function Pe(e,t){return Pe=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},Pe(e,t)}var Oe="STOPPED",Fe="IDLE",Me="KEY_LOADING",Ne="FRAG_LOADING",Ue="FRAG_LOADING_WAITING_RETRY",Be="PARSING",Ge="PARSED",Ve="ENDED",He="ERROR",je="WAITING_LEVEL",Ke=function(e){var t,r;function i(t,r,i){var n;return(n=e.call(this)||this).hls=void 0,n.fragPrevious=null,n.fragCurrent=null,n.fragmentTracker=void 0,n.transmuxer=null,n._state=Oe,n.media=null,n.mediaBuffer=null,n.config=void 0,n.bitrateTest=!1,n.lastCurrentTime=0,n.nextLoadPosition=0,n.startPosition=0,n.loadedmetadata=!1,n.fragLoadError=0,n.retryDate=0,n.levels=null,n.fragmentLoader=void 0,n.levelLastLoaded=null,n.startFragRequested=!1,n.decrypter=void 0,n.initPTS=[],n.onvseeking=null,n.onvended=null,n.logPrefix="",n.log=void 0,n.warn=void 0,n.logPrefix=i,n.log=l.logger.log.bind(l.logger,i+":"),n.warn=l.logger.warn.bind(l.logger,i+":"),n.hls=t,n.fragmentLoader=new Re(t.config),n.fragmentTracker=r,n.config=t.config,n.decrypter=new xe.default(t,t.config),t.on(s.Events.KEY_LOADED,n.onKeyLoaded,Ce(n)),t.on(s.Events.LEVEL_SWITCHING,n.onLevelSwitching,Ce(n)),n}r=e,(t=i).prototype=Object.create(r.prototype),t.prototype.constructor=t,Pe(t,r);var n,d,h=i.prototype;return h.doTick=function(){this.onTickEnd()},h.onTickEnd=function(){},h.startLoad=function(e){},h.stopLoad=function(){this.fragmentLoader.abort();var e=this.fragCurrent;e&&this.fragmentTracker.removeFragment(e),this.resetTransmuxer(),this.fragCurrent=null,this.fragPrevious=null,this.clearInterval(),this.clearNextTick(),this.state=Oe},h._streamEnded=function(e,t){var r=this.fragCurrent,i=this.fragmentTracker;if(!t.live&&r&&this.media&&r.sn>=t.endSN&&!e.nextStart){var n=t.partList;if(null!=n&&n.length){var a=n[n.length-1];return ce.isBuffered(this.media,a.start+a.duration/2)}var s=i.getState(r);return s===ae.PARTIAL||s===ae.OK}return!1},h.onMediaAttached=function(e,t){var r=this.media=this.mediaBuffer=t.media;this.onvseeking=this.onMediaSeeking.bind(this),this.onvended=this.onMediaEnded.bind(this),r.addEventListener("seeking",this.onvseeking),r.addEventListener("ended",this.onvended);var i=this.config;this.levels&&i.autoStartLoad&&this.state===Oe&&this.startLoad(i.startPosition)},h.onMediaDetaching=function(){var e=this.media;null!=e&&e.ended&&(this.log("MSE detaching and video ended, reset startPosition"),this.startPosition=this.lastCurrentTime=0),e&&this.onvseeking&&this.onvended&&(e.removeEventListener("seeking",this.onvseeking),e.removeEventListener("ended",this.onvended),this.onvseeking=this.onvended=null),this.media=this.mediaBuffer=null,this.loadedmetadata=!1,this.fragmentTracker.removeAllFragments(),this.stopLoad()},h.onMediaSeeking=function(){var e=this.config,t=this.fragCurrent,r=this.media,i=this.mediaBuffer,n=this.state,s=r?r.currentTime:0,o=ce.bufferInfo(i||r,s,e.maxBufferHole);if(this.log("media seeking to "+((0,a.isFiniteNumber)(s)?s.toFixed(3):s)+", state: "+n),n===Ve)this.resetLoadingState();else if(t){var l=e.maxFragLookUpTolerance,u=t.start-l,d=t.start+t.duration+l;if(!o.len||d<o.start||u>o.end){var h=s>d;(s<u||h)&&(h&&t.loader&&(this.log("seeking outside of buffer while fragment load in progress, cancel fragment load"),t.loader.abort()),this.resetLoadingState())}}r&&(this.lastCurrentTime=s),this.loadedmetadata||o.len||(this.nextLoadPosition=this.startPosition=s),this.tickImmediate()},h.onMediaEnded=function(){this.startPosition=this.lastCurrentTime=0},h.onKeyLoaded=function(e,t){if(this.state===Me&&t.frag===this.fragCurrent&&this.levels){this.state=Fe;var r=this.levels[t.frag.level].details;r&&this.loadFragment(t.frag,r,t.frag.start)}},h.onLevelSwitching=function(e,t){this.fragLoadError=0},h.onHandlerDestroying=function(){this.stopLoad(),e.prototype.onHandlerDestroying.call(this)},h.onHandlerDestroyed=function(){this.state=Oe,this.hls.off(s.Events.KEY_LOADED,this.onKeyLoaded,this),this.hls.off(s.Events.LEVEL_SWITCHING,this.onLevelSwitching,this),this.fragmentLoader&&this.fragmentLoader.destroy(),this.decrypter&&this.decrypter.destroy(),this.hls=this.log=this.warn=this.decrypter=this.fragmentLoader=this.fragmentTracker=null,e.prototype.onHandlerDestroyed.call(this)},h.loadKey=function(e,t){this.log("Loading key for "+e.sn+" of ["+t.startSN+"-"+t.endSN+"], "+("[stream-controller]"===this.logPrefix?"level":"track")+" "+e.level),this.state=Me,this.fragCurrent=e,this.hls.trigger(s.Events.KEY_LOADING,{frag:e})},h.loadFragment=function(e,t,r){this._loadFragForPlayback(e,t,r)},h._loadFragForPlayback=function(e,t,r){var i=this;this._doFragLoad(e,t,r,(function(t){if(i.fragContextChanged(e))return i.warn("Fragment "+e.sn+(t.part?" p: "+t.part.index:"")+" of level "+e.level+" was dropped during download."),void i.fragmentTracker.removeFragment(e);e.stats.chunkCount++,i._handleFragmentLoadProgress(t)})).then((function(t){if(t){i.fragLoadError=0;var r=i.state;i.fragContextChanged(e)?(r===Ne||!i.fragCurrent&&r===Be)&&(i.fragmentTracker.removeFragment(e),i.state=Fe):("payload"in t&&(i.log("Loaded fragment "+e.sn+" of level "+e.level),i.hls.trigger(s.Events.FRAG_LOADED,t)),i._handleFragmentLoadComplete(t))}})).catch((function(t){i.state!==Oe&&i.state!==He&&(i.warn(t),i.resetFragmentLoading(e))}))},h.flushMainBuffer=function(e,t,r){if(void 0===r&&(r=null),e-t){var i={startOffset:e,endOffset:t,type:r};this.fragLoadError=0,this.hls.trigger(s.Events.BUFFER_FLUSHING,i)}},h._loadInitSegment=function(e){var t=this;this._doFragLoad(e).then((function(r){if(!r||t.fragContextChanged(e)||!t.levels)throw new Error("init load aborted");return r})).then((function(r){var i=t.hls,n=r.payload,a=e.decryptdata;if(n&&n.byteLength>0&&a&&a.key&&a.iv&&"AES-128"===a.method){var o=self.performance.now();return t.decrypter.webCryptoDecrypt(new Uint8Array(n),a.key.buffer,a.iv.buffer).then((function(t){var n=self.performance.now();return i.trigger(s.Events.FRAG_DECRYPTED,{frag:e,payload:t,stats:{tstart:o,tdecrypt:n}}),r.payload=t,r}))}return r})).then((function(r){var i=t.fragCurrent,n=t.hls,a=t.levels;if(!a)throw new Error("init load aborted, missing levels");a[e.level].details;var o=e.stats;t.state=Fe,t.fragLoadError=0,e.data=new Uint8Array(r.payload),o.parsing.start=o.buffering.start=self.performance.now(),o.parsing.end=o.buffering.end=self.performance.now(),r.frag===i&&n.trigger(s.Events.FRAG_BUFFERED,{stats:o,frag:i,part:null,id:e.type}),t.tick()})).catch((function(r){t.state!==Oe&&t.state!==He&&(t.warn(r),t.resetFragmentLoading(e))}))},h.fragContextChanged=function(e){var t=this.fragCurrent;return!e||!t||e.level!==t.level||e.sn!==t.sn||e.urlId!==t.urlId},h.fragBufferedComplete=function(e,t){var r,i,n=this.mediaBuffer?this.mediaBuffer:this.media;this.log("Buffered "+e.type+" sn: "+e.sn+(t?" part: "+t.index:"")+" of "+("[stream-controller]"===this.logPrefix?"level":"track")+" "+e.level+" "+(n?Ie(ce.getBuffered(n)):"(detached)")),this.state=Fe,n&&(!this.loadedmetadata&&e.type==C.PlaylistLevelType.MAIN&&n.buffered.length&&(null===(r=this.fragCurrent)||void 0===r?void 0:r.sn)===(null===(i=this.fragPrevious)||void 0===i?void 0:i.sn)&&(this.loadedmetadata=!0,this.seekToStartPos()),this.tick())},h.seekToStartPos=function(){},h._handleFragmentLoadComplete=function(e){var t=this.transmuxer;if(t){var r=e.frag,i=e.part,n=e.partsLoaded,a=!n||0===n.length||n.some((function(e){return!e})),s=new ve(r.level,r.sn,r.stats.chunkCount+1,0,i?i.index:-1,!a);t.flush(s)}},h._handleFragmentLoadProgress=function(e){},h._doFragLoad=function(e,t,r,i){var n=this;if(void 0===r&&(r=null),!this.levels)throw new Error("frag load aborted, missing levels");if(r=Math.max(e.start,r||0),this.config.lowLatencyMode&&t){var o=t.partList;if(o&&i){r>e.end&&t.fragmentHint&&(e=t.fragmentHint);var l=this.getNextPart(o,e,r);if(l>-1){var u=o[l];return this.log("Loading part sn: "+e.sn+" p: "+u.index+" cc: "+e.cc+" of playlist ["+t.startSN+"-"+t.endSN+"] parts [0-"+l+"-"+(o.length-1)+"] "+("[stream-controller]"===this.logPrefix?"level":"track")+": "+e.level+", target: "+parseFloat(r.toFixed(3))),this.nextLoadPosition=u.start+u.duration,this.state=Ne,this.hls.trigger(s.Events.FRAG_LOADING,{frag:e,part:o[l],targetBufferTime:r}),this.doFragPartsLoad(e,o,l,i).catch((function(e){return n.handleFragLoadError(e)}))}if(!e.url||this.loadedEndOfParts(o,r))return Promise.resolve(null)}}return this.log("Loading fragment "+e.sn+" cc: "+e.cc+" "+(t?"of ["+t.startSN+"-"+t.endSN+"] ":"")+("[stream-controller]"===this.logPrefix?"level":"track")+": "+e.level+", target: "+parseFloat(r.toFixed(3))),(0,a.isFiniteNumber)(e.sn)&&!this.bitrateTest&&(this.nextLoadPosition=e.start+e.duration),this.state=Ne,this.hls.trigger(s.Events.FRAG_LOADING,{frag:e,targetBufferTime:r}),this.fragmentLoader.load(e,i).catch((function(e){return n.handleFragLoadError(e)}))},h.doFragPartsLoad=function(e,t,r,i){var n=this;return new Promise((function(a,o){var l=[];!function r(u){var d=t[u];n.fragmentLoader.loadPart(e,d,i).then((function(i){l[d.index]=i;var o=i.part;n.hls.trigger(s.Events.FRAG_LOADED,i);var h=t[u+1];if(!h||h.fragment!==e)return a({frag:e,part:o,partsLoaded:l});r(u+1)})).catch(o)}(r)}))},h.handleFragLoadError=function(e){var t=e.data;return t&&t.details===o.ErrorDetails.INTERNAL_ABORTED?this.handleFragLoadAborted(t.frag,t.part):this.hls.trigger(s.Events.ERROR,t),null},h._handleTransmuxerFlush=function(e){var t=this.getCurrentContext(e);if(t&&this.state===Be){var r=t.frag,i=t.part,n=t.level,a=self.performance.now();r.stats.parsing.end=a,i&&(i.stats.parsing.end=a),this.updateLevelTiming(r,i,n,e.partial)}else this.fragCurrent||(this.state=Fe)},h.getCurrentContext=function(e){var t=this.levels,r=e.level,i=e.sn,n=e.part;if(!t||!t[r])return this.warn("Levels object was unset while buffering fragment "+i+" of level "+r+". The current chunk will not be buffered."),null;var a=t[r],s=n>-1?function(e,t,r){if(!e||!e.details)return null;var i=e.details.partList;if(i)for(var n=i.length;n--;){var a=i[n];if(a.index===r&&a.fragment.sn===t)return a}return null}(a,i,n):null,o=s?s.fragment:function(e,t,r){if(!e||!e.details)return null;var i=e.details,n=i.fragments[t-i.startSN];return n||((n=i.fragmentHint)&&n.sn===t?n:t<i.startSN&&r&&r.sn===t?r:null)}(a,i,this.fragCurrent);return o?{frag:o,part:s,level:a}:null},h.bufferFragmentData=function(e,t,r,i){if(e&&this.state===Be){var n=e.data1,a=e.data2,o=n;if(n&&a&&(o=(0,u.appendUint8Array)(n,a)),o&&o.length){var l={type:e.type,frag:t,part:r,chunkMeta:i,parent:t.type,data:o};this.hls.trigger(s.Events.BUFFER_APPENDING,l),e.dropped&&e.independent&&!r&&this.flushBufferGap(t)}}},h.flushBufferGap=function(e){var t=this.media;if(t)if(ce.isBuffered(t,t.currentTime)){var r=t.currentTime,i=ce.bufferInfo(t,r,0),n=e.duration,a=Math.min(2*this.config.maxFragLookUpTolerance,.25*n),s=Math.max(Math.min(e.start-a,i.end-a),r+a);e.start-s>a&&this.flushMainBuffer(s,e.start)}else this.flushMainBuffer(0,e.start)},h.getFwdBufferInfo=function(e,t){var r=this.config,i=this.getLoadPosition();if(!(0,a.isFiniteNumber)(i))return null;var n=ce.bufferInfo(e,i,r.maxBufferHole);if(0===n.len&&void 0!==n.nextStart){var s=this.fragmentTracker.getBufferedFrag(i,t);if(s&&n.nextStart<s.end)return ce.bufferInfo(e,i,Math.max(n.nextStart,r.maxBufferHole))}return n},h.getMaxBufferLength=function(e){var t,r=this.config;return t=e?Math.max(8*r.maxBufferSize/e,r.maxBufferLength):r.maxBufferLength,Math.min(t,r.maxMaxBufferLength)},h.reduceMaxBufferLength=function(e){var t=this.config,r=e||t.maxBufferLength;return t.maxMaxBufferLength>=r&&(t.maxMaxBufferLength/=2,this.warn("Reduce max buffer length to "+t.maxMaxBufferLength+"s"),!0)},h.getNextFragment=function(e,t){var r=t.fragments,i=r.length;if(!i)return null;var n,a=this.config,s=r[0].start;if(t.live){var o=a.initialLiveManifestSize;if(i<o)return this.warn("Not enough fragments to start playback (have: "+i+", need: "+o+")"),null;t.PTSKnown||this.startFragRequested||-1!==this.startPosition||(n=this.getInitialLiveFragment(t,r),this.startPosition=n?this.hls.liveSyncPosition||n.start:e)}else e<=s&&(n=r[0]);if(!n){var l=a.lowLatencyMode?t.partEnd:t.fragmentEnd;n=this.getFragmentAtPosition(e,l,t)}return this.mapToInitFragWhenRequired(n)},h.mapToInitFragWhenRequired=function(e){return null==e||!e.initSegment||null!=e&&e.initSegment.data||this.bitrateTest?e:e.initSegment},h.getNextPart=function(e,t,r){for(var i=-1,n=!1,a=!0,s=0,o=e.length;s<o;s++){var l=e[s];if(a=a&&!l.independent,i>-1&&r<l.start)break;var u=l.loaded;!u&&(n||l.independent||a)&&l.fragment===t&&(i=s),n=u}return i},h.loadedEndOfParts=function(e,t){var r=e[e.length-1];return r&&t>r.start&&r.loaded},h.getInitialLiveFragment=function(e,t){var r=this.fragPrevious,i=null;if(r){if(e.hasProgramDateTime&&(this.log("Live playlist, switching playlist, load frag with same PDT: "+r.programDateTime),i=function(e,t,r){if(null===t||!Array.isArray(e)||!e.length||!(0,a.isFiniteNumber)(t))return null;if(t<(e[0].programDateTime||0))return null;if(t>=(e[e.length-1].endProgramDateTime||0))return null;r=r||0;for(var i=0;i<e.length;++i){var n=e[i];if(Ee(t,r,n))return n}return null}(t,r.endProgramDateTime,this.config.maxFragLookUpTolerance)),!i){var n=r.sn+1;if(n>=e.startSN&&n<=e.endSN){var s=t[n-e.startSN];r.cc===s.cc&&(i=s,this.log("Live playlist, switching playlist, load frag with next SN: "+i.sn))}i||(i=function(e,t){return me(e,(function(e){return e.cc<t?1:e.cc>t?-1:0}))}(t,r.cc),i&&this.log("Live playlist, switching playlist, load frag with same CC: "+i.sn))}}else{var o=this.hls.liveSyncPosition;null!==o&&(i=this.getFragmentAtPosition(o,this.bitrateTest?e.fragmentEnd:e.edge,e))}return i},h.getFragmentAtPosition=function(e,t,r){var i,n=this.config,a=this.fragPrevious,s=r.fragments,o=r.endSN,l=r.fragmentHint,u=n.maxFragLookUpTolerance,d=!!(n.lowLatencyMode&&r.partList&&l);if(d&&l&&!this.bitrateTest&&(s=s.concat(l),o=l.sn),i=e<t?function(e,t,r,i){void 0===r&&(r=0),void 0===i&&(i=0);var n=null;return e?n=t[e.sn-t[0].sn+1]||null:0===r&&0===t[0].start&&(n=t[0]),n&&0===ye(r,i,n)?n:me(t,ye.bind(null,r,i))||n}(a,s,e,e>t-u?0:u):s[s.length-1],i){var h=i.sn-r.startSN;if(this.fragmentTracker.getState(i)===ae.OK&&(a=i),a&&i.sn===a.sn&&!d&&a&&i.level===a.level){var f=s[h+1];i.sn<o&&this.fragmentTracker.getState(f)!==ae.OK?(this.log("SN "+i.sn+" just loaded, load next one: "+f.sn),i=f):i=null}}return i},h.synchronizeToLiveEdge=function(e){var t=this.config,r=this.media;if(r){var i=this.hls.liveSyncPosition,n=r.currentTime,a=e.fragments[0].start,s=e.edge,o=n>=a-t.maxFragLookUpTolerance&&n<=s;if(null!==i&&r.duration>i&&(n<i||!o)){var l=void 0!==t.liveMaxLatencyDuration?t.liveMaxLatencyDuration:t.liveMaxLatencyDurationCount*e.targetduration;(!o&&r.readyState<4||n<s-l)&&(this.loadedmetadata||(this.nextLoadPosition=i),r.readyState&&(this.warn("Playback: "+n.toFixed(3)+" is located too far from the end of live sliding playlist: "+s+", reset currentTime to : "+i.toFixed(3)),r.currentTime=i))}}},h.alignPlaylists=function(e,t){var r=this.levels,i=this.levelLastLoaded,n=this.fragPrevious,s=null!==i?r[i]:null,o=e.fragments.length;if(!o)return this.warn("No fragments in live playlist"),0;var u=e.fragments[0].start,d=!t,h=e.alignedSliding&&(0,a.isFiniteNumber)(u);if(d||!h&&!u){!function(e,t,r){t&&(function(e,t,r){if(function(e,t,r){return!(!t.details||!(r.endCC>r.startCC||e&&e.cc<r.startCC))}(e,r,t)){var i=function(e,t,r){void 0===r&&(r=0);var i=e.fragments,n=t.fragments;if(n.length&&i.length){var a=function(e,t){for(var r=null,i=0,n=e.length;i<n;i++){var a=e[i];if(a&&a.cc===t){r=a;break}}return r}(i,n[0].cc);if(a&&(!a||a.startPTS))return a;l.logger.log("No frag in previous level to align on")}else l.logger.log("No fragments to align")}(r.details,t);i&&(0,a.isFiniteNumber)(i.start)&&(l.logger.log("Adjusting PTS using last level due to CC increase within current level "+t.url),pe(i.start,t))}}(e,r,t),!r.alignedSliding&&t.details&&function(e,t){if(t.fragments.length&&e.hasProgramDateTime&&t.hasProgramDateTime){var r=t.fragments[0].programDateTime,i=e.fragments[0].programDateTime,n=(i-r)/1e3+t.fragments[0].start;n&&(0,a.isFiniteNumber)(n)&&(l.logger.log("Adjusting PTS using programDateTime delta "+(i-r)+"ms, sliding:"+n.toFixed(3)+" "+e.url+" "),pe(n,e))}}(r,t.details),r.alignedSliding||!t.details||r.skippedSegments||ee(t.details,r))}(n,s,e);var f=e.fragments[0].start;return this.log("Live playlist sliding: "+f.toFixed(2)+" start-sn: "+(t?t.startSN:"na")+"->"+e.startSN+" prev-sn: "+(n?n.sn:"na")+" fragments: "+o),f}return u},h.waitForCdnTuneIn=function(e){return e.live&&e.canBlockReload&&e.partTarget&&e.tuneInGoal>Math.max(e.partHoldBack,3*e.partTarget)},h.setStartPosition=function(e,t){var r=this.startPosition;if(r<t&&(r=-1),-1===r||-1===this.lastCurrentTime){var i=e.startTimeOffset;(0,a.isFiniteNumber)(i)?(r=t+i,i<0&&(r+=e.totalduration),r=Math.min(Math.max(t,r),t+e.totalduration),this.log("Start time offset "+i+" found in playlist, adjust startPosition to "+r),this.startPosition=r):e.live?r=this.hls.liveSyncPosition||t:this.startPosition=r=0,this.lastCurrentTime=r}this.nextLoadPosition=r},h.getLoadPosition=function(){var e=this.media,t=0;return this.loadedmetadata&&e?t=e.currentTime:this.nextLoadPosition&&(t=this.nextLoadPosition),t},h.handleFragLoadAborted=function(e,t){this.transmuxer&&"initSegment"!==e.sn&&e.stats.aborted&&(this.warn("Fragment "+e.sn+(t?" part"+t.index:"")+" of level "+e.level+" was aborted"),this.resetFragmentLoading(e))},h.resetFragmentLoading=function(e){this.fragCurrent&&(this.fragContextChanged(e)||this.state===Ue)||(this.state=Fe)},h.onFragmentOrKeyLoadError=function(e,t){if(!t.fatal){var r=t.frag;if(r&&r.type===e){this.fragCurrent;var i=this.config;if(this.fragLoadError+1<=i.fragLoadingMaxRetry){this.loadedmetadata||(this.startFragRequested=!1,this.nextLoadPosition=this.startPosition);var n=Math.min(Math.pow(2,this.fragLoadError)*i.fragLoadingRetryDelay,i.fragLoadingMaxRetryTimeout);this.warn("Fragment "+r.sn+" of "+e+" "+r.level+" failed to load, retrying in "+n+"ms"),this.retryDate=self.performance.now()+n,this.fragLoadError++,this.state=Ue}else t.levelRetry?(e===C.PlaylistLevelType.AUDIO&&(this.fragCurrent=null),this.fragLoadError=0,this.state=Fe):(l.logger.error(t.details+" reaches max retry, redispatch as fatal ..."),t.fatal=!0,this.hls.stopLoad(),this.state=He)}}},h.afterBufferFlushed=function(e,t,r){if(e){var i=ce.getBuffered(e);this.fragmentTracker.detectEvictedFragments(t,i,r),this.state===Ve&&this.resetLoadingState()}},h.resetLoadingState=function(){this.fragCurrent=null,this.fragPrevious=null,this.state=Fe},h.resetStartWhenNotLoaded=function(e){if(!this.loadedmetadata){this.startFragRequested=!1;var t=this.levels?this.levels[e].details:null;null!=t&&t.live?(this.startPosition=-1,this.setStartPosition(t,0),this.resetLoadingState()):this.nextLoadPosition=this.startPosition}},h.updateLevelTiming=function(e,t,r,i){var n=this,a=r.details;Object.keys(e.elementaryStreams).reduce((function(t,o){var l=e.elementaryStreams[o];if(l){var u=l.endPTS-l.startPTS;if(u<=0)return n.warn("Could not parse fragment "+e.sn+" "+o+" duration reliably ("+u+")"),t||!1;var d=i?0:J(a,e,l.startPTS,l.endPTS,l.startDTS,l.endDTS);return n.hls.trigger(s.Events.LEVEL_PTS_UPDATED,{details:a,level:r,drift:d,type:o,frag:e,start:l.startPTS,end:l.endPTS}),!0}return t}),!1)||(this.warn("Found no media in fragment "+e.sn+" of level "+r.id+" resetting transmuxer to fallback to playlist timing"),this.resetTransmuxer()),this.state=Ge,this.hls.trigger(s.Events.FRAG_PARSED,{frag:e,part:t})},h.resetTransmuxer=function(){this.transmuxer&&(this.transmuxer.destroy(),this.transmuxer=null)},n=i,(d=[{key:"state",get:function(){return this._state},set:function(e){var t=this._state;t!==e&&(this._state=e,this.log(t+"->"+e))}}])&&we(n.prototype,d),Object.defineProperty(n,"prototype",{writable:!1}),i}(he);function We(){return self.MediaSource||self.WebKitMediaSource}function Ye(){return self.SourceBuffer||self.WebKitSourceBuffer}var qe=function(){var e=ENTRY_MODULE,t={},r=function r(i){var n=t[i];if(void 0!==n)return n.exports;var a=t[i]={exports:{}};return e[i].call(a.exports,a,a.exports,r),a.exports};r.m=e,r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,{a:t}),t},r.d=function(e,t){for(var i in t)r.o(t,i)&&!r.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var i=r(ENTRY_MODULE);return i.default||i}.toString().split("ENTRY_MODULE"),ze="\\(\\s*(/\\*.*?\\*/)?\\s*.*?([\\.|\\-|\\+|\\w|/|@]+).*?\\)";function Xe(e){return(e+"").replace(/[.?*+^$[\]\\(){}|-]/g,"\\$&")}function Qe(e,t,i){var n={};n[i]=[];var a=t.toString().replace(/^"[^"]+"/,"function"),s=a.match(/^function\s?\w*\(\w+,\s*\w+,\s*(\w+)\)/)||a.match(/^\(\w+,\s*\w+,\s*(\w+)\)\s?\=\s?\>/);if(!s)return n;for(var o,l=s[1],u=new RegExp("(\\\\n|\\W)"+Xe(l)+ze,"g");o=u.exec(a);)"dll-reference"!==o[3]&&n[i].push(o[3]);for(u=new RegExp("\\("+Xe(l)+'\\("(dll-reference\\s([\\.|\\-|\\+|\\w|/|@]+))"\\)\\)'+ze,"g");o=u.exec(a);)e[o[2]]||(n[i].push(o[1]),e[o[2]]=r(o[1]).m),n[o[2]]=n[o[2]]||[],n[o[2]].push(o[4]);for(var d,h=Object.keys(n),f=0;f<h.length;f++)for(var c=0;c<n[h[f]].length;c++)d=n[h[f]][c],isNaN(1*d)||(n[h[f]][c]=1*n[h[f]][c]);return n}function $e(e){return Object.keys(e).reduce((function(t,r){return t||e[r].length>0}),!1)}function Ze(e,t,r,i){var n=e[i].map((function(e){return'"'+e+'": '+t[i][e].toString().replace(/^"[^"]+"/,"function")})).join(",");return qe[0]+"{"+n+"}"+qe[1]+'"'+r+'"'+qe[2]}var Je=r(764),et=r(729),tt=We()||{isTypeSupported:function(){return!1}},rt=function(){function e(e,t,i,n){var a=this;this.hls=void 0,this.id=void 0,this.observer=void 0,this.frag=null,this.part=null,this.useWorker=void 0,this.worker=void 0,this.onwmsg=void 0,this.transmuxer=null,this.onTransmuxComplete=void 0,this.onFlush=void 0;var u=e.config;this.hls=e,this.id=t,this.useWorker=!!u.enableWorker,this.onTransmuxComplete=i,this.onFlush=n;var d=function(e,t){(t=t||{}).frag=a.frag,t.id=a.id,a.hls.trigger(e,t)};this.observer=new et.EventEmitter,this.observer.on(s.Events.FRAG_DECRYPTED,d),this.observer.on(s.Events.ERROR,d);var h={mp4:tt.isTypeSupported("video/mp4"),mpeg:tt.isTypeSupported("audio/mpeg"),mp3:tt.isTypeSupported('audio/mp4; codecs="mp3"')},f=navigator.vendor;if(this.useWorker&&"undefined"!=typeof Worker){var c;l.logger.log("demuxing in webworker");try{c=this.worker=function(e,t){t=t||{};var i={main:r.m},n=t.all?{main:Object.keys(i.main)}:function(e,t){for(var r={main:[t]},i={main:[]},n={main:{}};$e(r);)for(var a=Object.keys(r),s=0;s<a.length;s++){var o=a[s],l=r[o].pop();if(n[o]=n[o]||{},!n[o][l]&&e[o][l]){n[o][l]=!0,i[o]=i[o]||[],i[o].push(l);for(var u=Qe(e,e[o][l],o),d=Object.keys(u),h=0;h<d.length;h++)r[d[h]]=r[d[h]]||[],r[d[h]]=r[d[h]].concat(u[d[h]])}}return i}(i,e),a="";Object.keys(n).filter((function(e){return"main"!==e})).forEach((function(e){for(var t=0;n[e][t];)t++;n[e].push(t),i[e][t]="(function(module, exports, __webpack_require__) { module.exports = __webpack_require__; })",a=a+"var "+e+" = ("+Ze(n,i,t,modules)+")();\n"})),a=a+"new (("+Ze(n,i,e,"main")+")())(self);";var s=new window.Blob([a],{type:"text/javascript"}),o=(window.URL||window.webkitURL||window.mozURL||window.msURL).createObjectURL(s),l=new window.Worker(o);return l.objectURL=o,l}(182),this.onwmsg=this.onWorkerMessage.bind(this),c.addEventListener("message",this.onwmsg),c.onerror=function(e){a.useWorker=!1,l.logger.warn("Exception in webworker, fallback to inline"),a.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.OTHER_ERROR,details:o.ErrorDetails.INTERNAL_EXCEPTION,fatal:!1,event:"demuxerWorker",error:new Error(e.message+" ("+e.filename+":"+e.lineno+")")})},c.postMessage({cmd:"init",typeSupported:h,vendor:f,id:t,config:JSON.stringify(u)})}catch(e){l.logger.warn("Error in worker:",e),l.logger.error("Error while initializing DemuxerWorker, fallback to inline"),c&&self.URL.revokeObjectURL(c.objectURL),this.transmuxer=new Je.default(this.observer,h,u,f,t),this.worker=null}}else this.transmuxer=new Je.default(this.observer,h,u,f,t)}var t=e.prototype;return t.destroy=function(){var e=this.worker;if(e)e.removeEventListener("message",this.onwmsg),e.terminate(),this.worker=null,this.onwmsg=void 0;else{var t=this.transmuxer;t&&(t.destroy(),this.transmuxer=null)}var r=this.observer;r&&r.removeAllListeners(),this.frag=null,this.observer=null,this.hls=null},t.push=function(e,t,r,i,n,a,s,o,u,d){var h,f,c=this;u.transmuxing.start=self.performance.now();var v=this.transmuxer,g=this.worker,p=a?a.start:n.start,m=n.decryptdata,y=this.frag,E=!(y&&n.cc===y.cc),T=!(y&&u.level===y.level),S=y?u.sn-y.sn:-1,b=this.part?u.part-this.part.index:-1,L=0===S&&u.id>1&&u.id===(null==y?void 0:y.stats.chunkCount),A=!T&&(1===S||0===S&&(1===b||L&&b<=0)),D=self.performance.now();(T||S||0===n.stats.parsing.start)&&(n.stats.parsing.start=D),!a||!b&&A||(a.stats.parsing.start=D);var R=!(y&&(null===(h=n.initSegment)||void 0===h?void 0:h.url)===(null===(f=y.initSegment)||void 0===f?void 0:f.url)),k=new Je.TransmuxState(E,A,o,T,p,R);if(!A||E||R){l.logger.log("[transmuxer-interface, "+n.type+"]: Starting new transmux session for sn: "+u.sn+" p: "+u.part+" level: "+u.level+" id: "+u.id+"\n discontinuity: "+E+"\n trackSwitch: "+T+"\n contiguous: "+A+"\n accurateTimeOffset: "+o+"\n timeOffset: "+p+"\n initSegmentChange: "+R);var _=new Je.TransmuxConfig(r,i,t,s,d);this.configureTransmuxer(_)}if(this.frag=n,this.part=a,g)g.postMessage({cmd:"demux",data:e,decryptdata:m,chunkMeta:u,state:k},e instanceof ArrayBuffer?[e]:[]);else if(v){var x=v.push(e,m,u,k);(0,Je.isPromise)(x)?x.then((function(e){c.handleTransmuxComplete(e)})):this.handleTransmuxComplete(x)}},t.flush=function(e){var t=this;e.transmuxing.start=self.performance.now();var r=this.transmuxer,i=this.worker;if(i)i.postMessage({cmd:"flush",chunkMeta:e});else if(r){var n=r.flush(e);(0,Je.isPromise)(n)?n.then((function(r){t.handleFlushResult(r,e)})):this.handleFlushResult(n,e)}},t.handleFlushResult=function(e,t){var r=this;e.forEach((function(e){r.handleTransmuxComplete(e)})),this.onFlush(t)},t.onWorkerMessage=function(e){var t=e.data,r=this.hls;switch(t.event){case"init":self.URL.revokeObjectURL(this.worker.objectURL);break;case"transmuxComplete":this.handleTransmuxComplete(t.data);break;case"flush":this.onFlush(t.data);break;case"workerLog":l.logger[t.data.logType]&&l.logger[t.data.logType](t.data.message);break;default:t.data=t.data||{},t.data.frag=this.frag,t.data.id=this.id,r.trigger(t.event,t.data)}},t.configureTransmuxer=function(e){var t=this.worker,r=this.transmuxer;t?t.postMessage({cmd:"configure",config:e}):r&&r.configure(e)},t.handleTransmuxComplete=function(e){e.chunkMeta.transmuxing.end=self.performance.now(),this.onTransmuxComplete(e)},e}(),it=function(){function e(e,t,r,i){this.config=void 0,this.media=null,this.fragmentTracker=void 0,this.hls=void 0,this.nudgeRetry=0,this.stallReported=!1,this.stalled=null,this.moved=!1,this.seeking=!1,this.config=e,this.media=t,this.fragmentTracker=r,this.hls=i}var t=e.prototype;return t.destroy=function(){this.media=null,this.hls=this.fragmentTracker=null},t.poll=function(e,t){var r=this.config,i=this.media,n=this.stalled;if(null!==i){var a=i.currentTime,s=i.seeking,o=this.seeking&&!s,u=!this.seeking&&s;if(this.seeking=s,a===e){if((u||o)&&(this.stalled=null),!(i.paused&&!s||i.ended||0===i.playbackRate)&&ce.getBuffered(i).length){var d=ce.bufferInfo(i,a,0),h=d.len>0,f=d.nextStart||0;if(h||f){if(s){var c=d.len>2,v=!f||t&&t.start<=a||f-a>2&&!this.fragmentTracker.getPartialFragment(a);if(c||v)return;this.moved=!1}if(!this.moved&&null!==this.stalled){var g,p=Math.max(f,d.start||0)-a,m=this.hls.levels?this.hls.levels[this.hls.currentLevel]:null,y=(null==m||null===(g=m.details)||void 0===g?void 0:g.live)?2*m.details.targetduration:2;if(p>0&&p<=y)return void this._trySkipBufferHole(null)}var E=self.performance.now();if(null!==n){var T=E-n;if(s||!(T>=250)||(this._reportStall(d),this.media)){var S=ce.bufferInfo(i,a,r.maxBufferHole);this._tryFixBufferStall(S,T)}}else this.stalled=E}}}else if(this.moved=!0,null!==n){if(this.stallReported){var b=self.performance.now()-n;l.logger.warn("playback not stuck anymore @"+a+", after "+Math.round(b)+"ms"),this.stallReported=!1}this.stalled=null,this.nudgeRetry=0}}},t._tryFixBufferStall=function(e,t){var r=this.config,i=this.fragmentTracker,n=this.media;if(null!==n){var a=n.currentTime,s=i.getPartialFragment(a);if(s&&(this._trySkipBufferHole(s)||!this.media))return;e.len>r.maxBufferHole&&t>1e3*r.highBufferWatchdogPeriod&&(l.logger.warn("Trying to nudge playhead over buffer-hole"),this.stalled=null,this._tryNudgeBuffer())}},t._reportStall=function(e){var t=this.hls,r=this.media;!this.stallReported&&r&&(this.stallReported=!0,l.logger.warn("Playback stalling at @"+r.currentTime+" due to low buffer ("+JSON.stringify(e)+")"),t.trigger(s.Events.ERROR,{type:o.ErrorTypes.MEDIA_ERROR,details:o.ErrorDetails.BUFFER_STALLED_ERROR,fatal:!1,buffer:e.len}))},t._trySkipBufferHole=function(e){var t=this.config,r=this.hls,i=this.media;if(null===i)return 0;for(var n=i.currentTime,a=0,u=ce.getBuffered(i),d=0;d<u.length;d++){var h=u.start(d);if(n+t.maxBufferHole>=a&&n<h){var f=Math.max(h+.05,i.currentTime+.1);return l.logger.warn("skipping hole, adjusting currentTime from "+n+" to "+f),this.moved=!0,this.stalled=null,i.currentTime=f,e&&r.trigger(s.Events.ERROR,{type:o.ErrorTypes.MEDIA_ERROR,details:o.ErrorDetails.BUFFER_SEEK_OVER_HOLE,fatal:!1,reason:"fragment loaded with buffer holes, seeking from "+n+" to "+f,frag:e}),f}a=u.end(d)}return 0},t._tryNudgeBuffer=function(){var e=this.config,t=this.hls,r=this.media,i=this.nudgeRetry;if(null!==r){var n=r.currentTime;if(this.nudgeRetry++,i<e.nudgeMaxRetry){var a=n+(i+1)*e.nudgeOffset;l.logger.warn("Nudging 'currentTime' from "+n+" to "+a),r.currentTime=a,t.trigger(s.Events.ERROR,{type:o.ErrorTypes.MEDIA_ERROR,details:o.ErrorDetails.BUFFER_NUDGE_ON_STALL,fatal:!1})}else l.logger.error("Playhead still not moving while enough data buffered @"+n+" after "+e.nudgeMaxRetry+" nudges"),t.trigger(s.Events.ERROR,{type:o.ErrorTypes.MEDIA_ERROR,details:o.ErrorDetails.BUFFER_STALLED_ERROR,fatal:!0})}},e}();function nt(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}function at(e,t){return at=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},at(e,t)}var st=function(e){var t,r;function i(t,r){var i;return(i=e.call(this,t,r,"[stream-controller]")||this).audioCodecSwap=!1,i.gapController=null,i.level=-1,i._forceStartLoad=!1,i.altAudio=!1,i.audioOnly=!1,i.fragPlaying=null,i.onvplaying=null,i.onvseeked=null,i.fragLastKbps=0,i.couldBacktrack=!1,i.backtrackFragment=null,i.audioCodecSwitch=!1,i.videoBuffer=null,i._registerListeners(),i}r=e,(t=i).prototype=Object.create(r.prototype),t.prototype.constructor=t,at(t,r);var n,l,u=i.prototype;return u._registerListeners=function(){var e=this.hls;e.on(s.Events.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(s.Events.MANIFEST_LOADING,this.onManifestLoading,this),e.on(s.Events.MANIFEST_PARSED,this.onManifestParsed,this),e.on(s.Events.LEVEL_LOADING,this.onLevelLoading,this),e.on(s.Events.LEVEL_LOADED,this.onLevelLoaded,this),e.on(s.Events.FRAG_LOAD_EMERGENCY_ABORTED,this.onFragLoadEmergencyAborted,this),e.on(s.Events.ERROR,this.onError,this),e.on(s.Events.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.on(s.Events.AUDIO_TRACK_SWITCHED,this.onAudioTrackSwitched,this),e.on(s.Events.BUFFER_CREATED,this.onBufferCreated,this),e.on(s.Events.BUFFER_FLUSHED,this.onBufferFlushed,this),e.on(s.Events.LEVELS_UPDATED,this.onLevelsUpdated,this),e.on(s.Events.FRAG_BUFFERED,this.onFragBuffered,this)},u._unregisterListeners=function(){var e=this.hls;e.off(s.Events.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(s.Events.MANIFEST_LOADING,this.onManifestLoading,this),e.off(s.Events.MANIFEST_PARSED,this.onManifestParsed,this),e.off(s.Events.LEVEL_LOADED,this.onLevelLoaded,this),e.off(s.Events.FRAG_LOAD_EMERGENCY_ABORTED,this.onFragLoadEmergencyAborted,this),e.off(s.Events.ERROR,this.onError,this),e.off(s.Events.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.off(s.Events.AUDIO_TRACK_SWITCHED,this.onAudioTrackSwitched,this),e.off(s.Events.BUFFER_CREATED,this.onBufferCreated,this),e.off(s.Events.BUFFER_FLUSHED,this.onBufferFlushed,this),e.off(s.Events.LEVELS_UPDATED,this.onLevelsUpdated,this),e.off(s.Events.FRAG_BUFFERED,this.onFragBuffered,this)},u.onHandlerDestroying=function(){this._unregisterListeners(),this.onMediaDetaching()},u.startLoad=function(e){if(this.levels){var t=this.lastCurrentTime,r=this.hls;if(this.stopLoad(),this.setInterval(100),this.level=-1,this.fragLoadError=0,!this.startFragRequested){var i=r.startLevel;-1===i&&(r.config.testBandwidth&&this.levels.length>1?(i=0,this.bitrateTest=!0):i=r.nextAutoLevel),this.level=r.nextLoadLevel=i,this.loadedmetadata=!1}t>0&&-1===e&&(this.log("Override startPosition with lastCurrentTime @"+t.toFixed(3)),e=t),this.state=Fe,this.nextLoadPosition=this.startPosition=this.lastCurrentTime=e,this.tick()}else this._forceStartLoad=!0,this.state=Oe},u.stopLoad=function(){this._forceStartLoad=!1,e.prototype.stopLoad.call(this)},u.doTick=function(){switch(this.state){case Fe:this.doTickIdle();break;case je:var e,t=this.levels,r=this.level,i=null==t||null===(e=t[r])||void 0===e?void 0:e.details;if(i&&(!i.live||this.levelLastLoaded===this.level)){if(this.waitForCdnTuneIn(i))break;this.state=Fe;break}break;case Ue:var n,a=self.performance.now(),s=this.retryDate;(!s||a>=s||null!==(n=this.media)&&void 0!==n&&n.seeking)&&(this.log("retryDate reached, switch back to IDLE state"),this.resetStartWhenNotLoaded(this.level),this.state=Fe)}this.onTickEnd()},u.onTickEnd=function(){e.prototype.onTickEnd.call(this),this.checkBuffer(),this.checkFragmentChanged()},u.doTickIdle=function(){var e,t,r=this.hls,i=this.levelLastLoaded,n=this.levels,a=this.media,o=r.config,l=r.nextLoadLevel;if(null!==i&&(a||!this.startFragRequested&&o.startFragPrefetch)&&(!this.altAudio||!this.audioOnly)&&n&&n[l]){var u=n[l];this.level=r.nextLoadLevel=l;var d=u.details;if(!d||this.state===je||d.live&&this.levelLastLoaded!==l)this.state=je;else{var h=this.getMainFwdBufferInfo();if(null!==h&&!(h.len>=this.getMaxBufferLength(u.maxBitrate))){if(this._streamEnded(h,d)){var f={};return this.altAudio&&(f.type="video"),this.hls.trigger(s.Events.BUFFER_EOS,f),void(this.state=Ve)}this.backtrackFragment&&this.backtrackFragment.start>h.end&&(this.backtrackFragment=null);var c=this.backtrackFragment?this.backtrackFragment.start:h.end,v=this.getNextFragment(c,d);if(this.couldBacktrack&&!this.fragPrevious&&v&&"initSegment"!==v.sn&&this.fragmentTracker.getState(v)!==ae.OK){var g,m=(null!=(g=this.backtrackFragment)?g:v).sn-d.startSN,y=d.fragments[m-1];y&&v.cc===y.cc&&(v=y,this.fragmentTracker.removeFragment(y))}else this.backtrackFragment&&h.len&&(this.backtrackFragment=null);if(v&&this.fragmentTracker.getState(v)===ae.OK&&this.nextLoadPosition>c){var E=this.audioOnly&&!this.altAudio?p.ElementaryStreamTypes.AUDIO:p.ElementaryStreamTypes.VIDEO;a&&this.afterBufferFlushed(a,E,C.PlaylistLevelType.MAIN),v=this.getNextFragment(this.nextLoadPosition,d)}v&&(!v.initSegment||v.initSegment.data||this.bitrateTest||(v=v.initSegment),"identity"!==(null===(e=v.decryptdata)||void 0===e?void 0:e.keyFormat)||null!==(t=v.decryptdata)&&void 0!==t&&t.key?this.loadFragment(v,d,c):this.loadKey(v,d))}}}},u.loadFragment=function(t,r,i){var n,a=this.fragmentTracker.getState(t);this.fragCurrent=t,a===ae.NOT_LOADED?"initSegment"===t.sn?this._loadInitSegment(t):this.bitrateTest?(this.log("Fragment "+t.sn+" of level "+t.level+" is being downloaded to test bitrate and will not be buffered"),this._loadBitrateTestFrag(t)):(this.startFragRequested=!0,e.prototype.loadFragment.call(this,t,r,i)):a===ae.APPENDING?this.reduceMaxBufferLength(t.duration)&&this.fragmentTracker.removeFragment(t):0===(null===(n=this.media)||void 0===n?void 0:n.buffered.length)&&this.fragmentTracker.removeAllFragments()},u.getAppendedFrag=function(e){var t=this.fragmentTracker.getAppendedFrag(e,C.PlaylistLevelType.MAIN);return t&&"fragment"in t?t.fragment:t},u.getBufferedFrag=function(e){return this.fragmentTracker.getBufferedFrag(e,C.PlaylistLevelType.MAIN)},u.followingBufferedFrag=function(e){return e?this.getBufferedFrag(e.end+.5):null},u.immediateLevelSwitch=function(){this.abortCurrentFrag(),this.flushMainBuffer(0,Number.POSITIVE_INFINITY)},u.nextLevelSwitch=function(){var e=this.levels,t=this.media;if(null!=t&&t.readyState){var r,i=this.getAppendedFrag(t.currentTime);if(i&&i.start>1&&this.flushMainBuffer(0,i.start-1),!t.paused&&e){var n=e[this.hls.nextLoadLevel],a=this.fragLastKbps;r=a&&this.fragCurrent?this.fragCurrent.duration*n.maxBitrate/(1e3*a)+1:0}else r=0;var s=this.getBufferedFrag(t.currentTime+r);if(s){var o=this.followingBufferedFrag(s);if(o){this.abortCurrentFrag();var l=o.maxStartPTS?o.maxStartPTS:o.start,u=o.duration,d=Math.max(s.end,l+Math.min(Math.max(u-this.config.maxFragLookUpTolerance,.5*u),.75*u));this.flushMainBuffer(d,Number.POSITIVE_INFINITY)}}}},u.abortCurrentFrag=function(){var e=this.fragCurrent;switch(this.fragCurrent=null,this.backtrackFragment=null,null!=e&&e.loader&&e.loader.abort(),this.state){case Me:case Ne:case Ue:case Be:case Ge:this.state=Fe}this.nextLoadPosition=this.getLoadPosition()},u.flushMainBuffer=function(t,r){e.prototype.flushMainBuffer.call(this,t,r,this.altAudio?"video":null)},u.onMediaAttached=function(t,r){e.prototype.onMediaAttached.call(this,t,r);var i=r.media;this.onvplaying=this.onMediaPlaying.bind(this),this.onvseeked=this.onMediaSeeked.bind(this),i.addEventListener("playing",this.onvplaying),i.addEventListener("seeked",this.onvseeked),this.gapController=new it(this.config,i,this.fragmentTracker,this.hls)},u.onMediaDetaching=function(){var t=this.media;t&&this.onvplaying&&this.onvseeked&&(t.removeEventListener("playing",this.onvplaying),t.removeEventListener("seeked",this.onvseeked),this.onvplaying=this.onvseeked=null,this.videoBuffer=null),this.fragPlaying=null,this.gapController&&(this.gapController.destroy(),this.gapController=null),e.prototype.onMediaDetaching.call(this)},u.onMediaPlaying=function(){this.tick()},u.onMediaSeeked=function(){var e=this.media,t=e?e.currentTime:null;(0,a.isFiniteNumber)(t)&&this.log("Media seeked to "+t.toFixed(3)),this.tick()},u.onManifestLoading=function(){this.log("Trigger BUFFER_RESET"),this.hls.trigger(s.Events.BUFFER_RESET,void 0),this.fragmentTracker.removeAllFragments(),this.couldBacktrack=!1,this.startPosition=this.lastCurrentTime=0,this.fragPlaying=null,this.backtrackFragment=null},u.onManifestParsed=function(e,t){var r,i,n,a=!1,s=!1;t.levels.forEach((function(e){(r=e.audioCodec)&&(-1!==r.indexOf("mp4a.40.2")&&(a=!0),-1!==r.indexOf("mp4a.40.5")&&(s=!0))})),this.audioCodecSwitch=a&&s&&!("function"==typeof(null==(n=Ye())||null===(i=n.prototype)||void 0===i?void 0:i.changeType)),this.audioCodecSwitch&&this.log("Both AAC/HE-AAC audio found in levels; declaring level codec as HE-AAC"),this.levels=t.levels,this.startFragRequested=!1},u.onLevelLoading=function(e,t){var r=this.levels;if(r&&this.state===Fe){var i=r[t.level];(!i.details||i.details.live&&this.levelLastLoaded!==t.level||this.waitForCdnTuneIn(i.details))&&(this.state=je)}},u.onLevelLoaded=function(e,t){var r,i=this.levels,n=t.level,a=t.details,o=a.totalduration;if(i){this.log("Level "+n+" loaded ["+a.startSN+","+a.endSN+"], cc ["+a.startCC+", "+a.endCC+"] duration:"+o);var l=this.fragCurrent;!l||this.state!==Ne&&this.state!==Ue||l.level!==t.level&&l.loader&&(this.state=Fe,this.backtrackFragment=null,l.loader.abort());var u=i[n],d=0;if(a.live||null!==(r=u.details)&&void 0!==r&&r.live){if(a.fragments[0]||(a.deltaUpdateFailed=!0),a.deltaUpdateFailed)return;d=this.alignPlaylists(a,u.details)}if(u.details=a,this.levelLastLoaded=n,this.hls.trigger(s.Events.LEVEL_UPDATED,{details:a,level:n}),this.state===je){if(this.waitForCdnTuneIn(a))return;this.state=Fe}this.startFragRequested?a.live&&this.synchronizeToLiveEdge(a):this.setStartPosition(a,d),this.tick()}else this.warn("Levels were reset while loading level "+n)},u._handleFragmentLoadProgress=function(e){var t,r=e.frag,i=e.part,n=e.payload,a=this.levels;if(a){var s=a[r.level],o=s.details;if(o){var l=s.videoCodec,u=o.PTSKnown||!o.live,d=null===(t=r.initSegment)||void 0===t?void 0:t.data,h=this._getAudioCodec(s),f=this.transmuxer=this.transmuxer||new rt(this.hls,C.PlaylistLevelType.MAIN,this._handleTransmuxComplete.bind(this),this._handleTransmuxerFlush.bind(this)),c=i?i.index:-1,v=-1!==c,g=new ve(r.level,r.sn,r.stats.chunkCount,n.byteLength,c,v),p=this.initPTS[r.cc];f.push(n,d,h,l,r,i,o.totalduration,u,g,p)}else this.warn("Dropping fragment "+r.sn+" of level "+r.level+" after level details were reset")}else this.warn("Levels were reset while fragment load was in progress. Fragment "+r.sn+" of level "+r.level+" will not be buffered")},u.onAudioTrackSwitching=function(e,t){var r=this.altAudio,i=!!t.url,n=t.id;if(!i){if(this.mediaBuffer!==this.media){this.log("Switching on main audio, use media.buffered to schedule main fragment loading"),this.mediaBuffer=this.media;var a=this.fragCurrent;null!=a&&a.loader&&(this.log("Switching to main audio track, cancel main fragment load"),a.loader.abort()),this.resetTransmuxer(),this.resetLoadingState()}else this.audioOnly&&this.resetTransmuxer();var o=this.hls;r&&o.trigger(s.Events.BUFFER_FLUSHING,{startOffset:0,endOffset:Number.POSITIVE_INFINITY,type:"audio"}),o.trigger(s.Events.AUDIO_TRACK_SWITCHED,{id:n})}},u.onAudioTrackSwitched=function(e,t){var r=t.id,i=!!this.hls.audioTracks[r].url;if(i){var n=this.videoBuffer;n&&this.mediaBuffer!==n&&(this.log("Switching on alternate audio, use video.buffered to schedule main fragment loading"),this.mediaBuffer=n)}this.altAudio=i,this.tick()},u.onBufferCreated=function(e,t){var r,i,n=t.tracks,a=!1;for(var s in n){var o=n[s];if("main"===o.id){if(i=s,r=o,"video"===s){var l=n[s];l&&(this.videoBuffer=l.buffer)}}else a=!0}a&&r?(this.log("Alternate track found, use "+i+".buffered to schedule main fragment loading"),this.mediaBuffer=r.buffer):this.mediaBuffer=this.media},u.onFragBuffered=function(e,t){var r=t.frag,i=t.part;if(!r||r.type===C.PlaylistLevelType.MAIN){if(this.fragContextChanged(r))return this.warn("Fragment "+r.sn+(i?" p: "+i.index:"")+" of level "+r.level+" finished buffering, but was aborted. state: "+this.state),void(this.state===Ge&&(this.state=Fe));var n=i?i.stats:r.stats;this.fragLastKbps=Math.round(8*n.total/(n.buffering.end-n.loading.first)),"initSegment"!==r.sn&&(this.fragPrevious=r),this.fragBufferedComplete(r,i)}},u.onError=function(e,t){switch(t.details){case o.ErrorDetails.FRAG_LOAD_ERROR:case o.ErrorDetails.FRAG_LOAD_TIMEOUT:case o.ErrorDetails.KEY_LOAD_ERROR:case o.ErrorDetails.KEY_LOAD_TIMEOUT:this.onFragmentOrKeyLoadError(C.PlaylistLevelType.MAIN,t);break;case o.ErrorDetails.LEVEL_LOAD_ERROR:case o.ErrorDetails.LEVEL_LOAD_TIMEOUT:this.state!==He&&(t.fatal?(this.warn(""+t.details),this.state=He):t.levelRetry||this.state!==je||(this.state=Fe));break;case o.ErrorDetails.BUFFER_FULL_ERROR:if("main"===t.parent&&(this.state===Be||this.state===Ge)){var r=!0,i=this.getFwdBufferInfo(this.media,C.PlaylistLevelType.MAIN);i&&i.len>.5&&(r=!this.reduceMaxBufferLength(i.len)),r&&(this.warn("buffer full error also media.currentTime is not buffered, flush main"),this.immediateLevelSwitch()),this.resetLoadingState()}}},u.checkBuffer=function(){var e=this.media,t=this.gapController;if(e&&t&&e.readyState){if(this.loadedmetadata||!ce.getBuffered(e).length){var r=this.state!==Fe?this.fragCurrent:null;t.poll(this.lastCurrentTime,r)}this.lastCurrentTime=e.currentTime}},u.onFragLoadEmergencyAborted=function(){this.state=Fe,this.loadedmetadata||(this.startFragRequested=!1,this.nextLoadPosition=this.startPosition),this.tickImmediate()},u.onBufferFlushed=function(e,t){var r=t.type;if(r!==p.ElementaryStreamTypes.AUDIO||this.audioOnly&&!this.altAudio){var i=(r===p.ElementaryStreamTypes.VIDEO?this.videoBuffer:this.mediaBuffer)||this.media;this.afterBufferFlushed(i,r,C.PlaylistLevelType.MAIN)}},u.onLevelsUpdated=function(e,t){this.levels=t.levels},u.swapAudioCodec=function(){this.audioCodecSwap=!this.audioCodecSwap},u.seekToStartPos=function(){var e=this.media;if(e){var t=e.currentTime,r=this.startPosition;if(r>=0&&t<r){if(e.seeking)return void this.log("could not seek to "+r+", already seeking at "+t);var i=ce.getBuffered(e),n=(i.length?i.start(0):0)-r;n>0&&(n<this.config.maxBufferHole||n<this.config.maxFragLookUpTolerance)&&(this.log("adjusting start position by "+n+" to match buffer start"),r+=n,this.startPosition=r),this.log("seek to target start position "+r+" from current time "+t),e.currentTime=r}}},u._getAudioCodec=function(e){var t=this.config.defaultAudioCodec||e.audioCodec;return this.audioCodecSwap&&t&&(this.log("Swapping audio codec"),t=-1!==t.indexOf("mp4a.40.5")?"mp4a.40.2":"mp4a.40.5"),t},u._loadBitrateTestFrag=function(e){var t=this;e.bitrateTest=!0,this._doFragLoad(e).then((function(r){var i=t.hls;if(r&&!i.nextLoadLevel&&!t.fragContextChanged(e)){t.fragLoadError=0,t.state=Fe,t.startFragRequested=!1,t.bitrateTest=!1;var n=e.stats;n.parsing.start=n.parsing.end=n.buffering.start=n.buffering.end=self.performance.now(),i.trigger(s.Events.FRAG_LOADED,r),e.bitrateTest=!1}}))},u._handleTransmuxComplete=function(e){var t,r="main",i=this.hls,n=e.remuxResult,o=e.chunkMeta,l=this.getCurrentContext(o);if(!l)return this.warn("The loading context changed while buffering fragment "+o.sn+" of level "+o.level+". This chunk will not be buffered."),void this.resetStartWhenNotLoaded(o.level);var u=l.frag,d=l.part,h=l.level,f=n.video,c=n.text,v=n.id3,g=n.initSegment,m=h.details,y=this.altAudio?void 0:n.audio;if(!this.fragContextChanged(u)){if(this.state=Be,g){g.tracks&&(this._bufferInitSegment(h,g.tracks,u,o),i.trigger(s.Events.FRAG_PARSING_INIT_SEGMENT,{frag:u,id:r,tracks:g.tracks}));var E=g.initPTS,T=g.timescale;(0,a.isFiniteNumber)(E)&&(this.initPTS[u.cc]=E,i.trigger(s.Events.INIT_PTS_FOUND,{frag:u,id:r,initPTS:E,timescale:T}))}if(f&&!1!==n.independent){if(m){var S=f.startPTS,b=f.endPTS,L=f.startDTS,A=f.endDTS;if(d)d.elementaryStreams[f.type]={startPTS:S,endPTS:b,startDTS:L,endDTS:A};else if(f.firstKeyFrame&&f.independent&&(this.couldBacktrack=!0),f.dropped&&f.independent){var D=this.getMainFwdBufferInfo();if((D?D.end:this.getLoadPosition())+this.config.maxBufferHole<(f.firstKeyFramePTS?f.firstKeyFramePTS:S)-this.config.maxBufferHole)return void this.backtrack(u);u.setElementaryStreamInfo(f.type,u.start,b,u.start,A,!0)}u.setElementaryStreamInfo(f.type,S,b,L,A),this.backtrackFragment&&(this.backtrackFragment=u),this.bufferFragmentData(f,u,d,o)}}else if(!1===n.independent)return void this.backtrack(u);if(y){var R=y.startPTS,k=y.endPTS,_=y.startDTS,x=y.endDTS;d&&(d.elementaryStreams[p.ElementaryStreamTypes.AUDIO]={startPTS:R,endPTS:k,startDTS:_,endDTS:x}),u.setElementaryStreamInfo(p.ElementaryStreamTypes.AUDIO,R,k,_,x),this.bufferFragmentData(y,u,d,o)}if(m&&null!=v&&null!==(t=v.samples)&&void 0!==t&&t.length){var I={id:r,frag:u,details:m,samples:v.samples};i.trigger(s.Events.FRAG_PARSING_METADATA,I)}if(m&&c){var w={id:r,frag:u,details:m,samples:c.samples};i.trigger(s.Events.FRAG_PARSING_USERDATA,w)}}},u._bufferInitSegment=function(e,t,r,i){var n=this;if(this.state===Be){this.audioOnly=!!t.audio&&!t.video,this.altAudio&&!this.audioOnly&&delete t.audio;var a=t.audio,o=t.video,l=t.audiovideo;if(a){var u=e.audioCodec,d=navigator.userAgent.toLowerCase();this.audioCodecSwitch&&(u&&(u=-1!==u.indexOf("mp4a.40.5")?"mp4a.40.2":"mp4a.40.5"),1!==a.metadata.channelCount&&-1===d.indexOf("firefox")&&(u="mp4a.40.5")),-1!==d.indexOf("android")&&"audio/mpeg"!==a.container&&(u="mp4a.40.2",this.log("Android: force audio codec to "+u)),e.audioCodec&&e.audioCodec!==u&&this.log('Swapping manifest audio codec "'+e.audioCodec+'" for "'+u+'"'),a.levelCodec=u,a.id="main",this.log("Init audio buffer, container:"+a.container+", codecs[selected/level/parsed]=["+(u||"")+"/"+(e.audioCodec||"")+"/"+a.codec+"]")}o&&(o.levelCodec=e.videoCodec,o.id="main",this.log("Init video buffer, container:"+o.container+", codecs[level/parsed]=["+(e.videoCodec||"")+"/"+o.codec+"]")),l&&this.log("Init audiovideo buffer, container:"+l.container+", codecs[level/parsed]=["+(e.attrs.CODECS||"")+"/"+l.codec+"]"),this.hls.trigger(s.Events.BUFFER_CODECS,t),Object.keys(t).forEach((function(e){var a=t[e].initSegment;null!=a&&a.byteLength&&n.hls.trigger(s.Events.BUFFER_APPENDING,{type:e,data:a,frag:r,part:null,chunkMeta:i,parent:r.type})})),this.tick()}},u.getMainFwdBufferInfo=function(){return this.getFwdBufferInfo(this.mediaBuffer?this.mediaBuffer:this.media,C.PlaylistLevelType.MAIN)},u.backtrack=function(e){this.couldBacktrack=!0,this.backtrackFragment=e,this.resetTransmuxer(),this.flushBufferGap(e),this.fragmentTracker.removeFragment(e),this.fragPrevious=null,this.nextLoadPosition=e.start,this.state=Fe},u.checkFragmentChanged=function(){var e=this.media,t=null;if(e&&e.readyState>1&&!1===e.seeking){var r=e.currentTime;if(ce.isBuffered(e,r)?t=this.getAppendedFrag(r):ce.isBuffered(e,r+.1)&&(t=this.getAppendedFrag(r+.1)),t){this.backtrackFragment=null;var i=this.fragPlaying,n=t.level;i&&t.sn===i.sn&&i.level===n&&t.urlId===i.urlId||(this.hls.trigger(s.Events.FRAG_CHANGED,{frag:t}),i&&i.level===n||this.hls.trigger(s.Events.LEVEL_SWITCHED,{level:n}),this.fragPlaying=t)}}},n=i,(l=[{key:"nextLevel",get:function(){var e=this.nextBufferedFrag;return e?e.level:-1}},{key:"currentFrag",get:function(){var e=this.media;return e?this.fragPlaying||this.getAppendedFrag(e.currentTime):null}},{key:"currentProgramDateTime",get:function(){var e=this.media;if(e){var t=e.currentTime,r=this.currentFrag;if(r&&(0,a.isFiniteNumber)(t)&&(0,a.isFiniteNumber)(r.programDateTime)){var i=r.programDateTime+1e3*(t-r.start);return new Date(i)}}return null}},{key:"currentLevel",get:function(){var e=this.currentFrag;return e?e.level:-1}},{key:"nextBufferedFrag",get:function(){var e=this.currentFrag;return e?this.followingBufferedFrag(e):null}},{key:"forceStartLoad",get:function(){return this._forceStartLoad}}])&&nt(n.prototype,l),Object.defineProperty(n,"prototype",{writable:!1}),i}(Ke);const ot=function(){function e(e,t,r){void 0===t&&(t=0),void 0===r&&(r=0),this.halfLife=void 0,this.alpha_=void 0,this.estimate_=void 0,this.totalWeight_=void 0,this.halfLife=e,this.alpha_=e?Math.exp(Math.log(.5)/e):0,this.estimate_=t,this.totalWeight_=r}var t=e.prototype;return t.sample=function(e,t){var r=Math.pow(this.alpha_,e);this.estimate_=t*(1-r)+r*this.estimate_,this.totalWeight_+=e},t.getTotalWeight=function(){return this.totalWeight_},t.getEstimate=function(){if(this.alpha_){var e=1-Math.pow(this.alpha_,this.totalWeight_);if(e)return this.estimate_/e}return this.estimate_},e}(),lt=function(){function e(e,t,r){this.defaultEstimate_=void 0,this.minWeight_=void 0,this.minDelayMs_=void 0,this.slow_=void 0,this.fast_=void 0,this.defaultEstimate_=r,this.minWeight_=.001,this.minDelayMs_=50,this.slow_=new ot(e),this.fast_=new ot(t)}var t=e.prototype;return t.update=function(e,t){var r=this.slow_,i=this.fast_;this.slow_.halfLife!==e&&(this.slow_=new ot(e,r.getEstimate(),r.getTotalWeight())),this.fast_.halfLife!==t&&(this.fast_=new ot(t,i.getEstimate(),i.getTotalWeight()))},t.sample=function(e,t){var r=(e=Math.max(e,this.minDelayMs_))/1e3,i=8*t/r;this.fast_.sample(r,i),this.slow_.sample(r,i)},t.canEstimate=function(){var e=this.fast_;return e&&e.getTotalWeight()>=this.minWeight_},t.getEstimate=function(){return this.canEstimate()?Math.min(this.fast_.getEstimate(),this.slow_.getEstimate()):this.defaultEstimate_},t.destroy=function(){},e}();function ut(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}const dt=function(){function e(e){this.hls=void 0,this.lastLoadedFragLevel=0,this._nextAutoLevel=-1,this.timer=void 0,this.onCheck=this._abandonRulesCheck.bind(this),this.fragCurrent=null,this.partCurrent=null,this.bitrateTestDelay=0,this.bwEstimator=void 0,this.hls=e;var t=e.config;this.bwEstimator=new lt(t.abrEwmaSlowVoD,t.abrEwmaFastVoD,t.abrEwmaDefaultEstimate),this.registerListeners()}var t,r,i=e.prototype;return i.registerListeners=function(){var e=this.hls;e.on(s.Events.FRAG_LOADING,this.onFragLoading,this),e.on(s.Events.FRAG_LOADED,this.onFragLoaded,this),e.on(s.Events.FRAG_BUFFERED,this.onFragBuffered,this),e.on(s.Events.LEVEL_LOADED,this.onLevelLoaded,this),e.on(s.Events.ERROR,this.onError,this)},i.unregisterListeners=function(){var e=this.hls;e.off(s.Events.FRAG_LOADING,this.onFragLoading,this),e.off(s.Events.FRAG_LOADED,this.onFragLoaded,this),e.off(s.Events.FRAG_BUFFERED,this.onFragBuffered,this),e.off(s.Events.LEVEL_LOADED,this.onLevelLoaded,this),e.off(s.Events.ERROR,this.onError,this)},i.destroy=function(){this.unregisterListeners(),this.clearTimer(),this.hls=this.onCheck=null,this.fragCurrent=this.partCurrent=null},i.onFragLoading=function(e,t){var r,i=t.frag;i.type===C.PlaylistLevelType.MAIN&&(this.timer||(this.fragCurrent=i,this.partCurrent=null!=(r=t.part)?r:null,this.timer=self.setInterval(this.onCheck,100)))},i.onLevelLoaded=function(e,t){var r=this.hls.config;t.details.live?this.bwEstimator.update(r.abrEwmaSlowLive,r.abrEwmaFastLive):this.bwEstimator.update(r.abrEwmaSlowVoD,r.abrEwmaFastVoD)},i._abandonRulesCheck=function(){var e=this.fragCurrent,t=this.partCurrent,r=this.hls,i=r.autoLevelEnabled,n=(r.config,r.media);if(e&&n){var o=t?t.stats:e.stats,u=t?t.duration:e.duration;if(o.aborted||o.loaded&&o.loaded===o.total||0===e.level)return this.clearTimer(),void(this._nextAutoLevel=-1);if(i&&!n.paused&&n.playbackRate&&n.readyState){var d=r.mainForwardBufferInfo;if(null!==d){var h=performance.now()-o.loading.start,f=Math.abs(n.playbackRate);if(!(h<=500*u/f)){var c=o.loaded&&o.loading.first,v=this.bwEstimator.getEstimate(),g=r.levels,p=r.minAutoLevel,m=g[e.level],y=o.total||Math.max(o.loaded,Math.round(u*m.maxBitrate/8)),E=c?1e3*o.loaded/h:0,T=E?(y-o.loaded)/E:8*y/v,S=d.len/f;if(!(T<=S)){var b,L=Number.POSITIVE_INFINITY;for(b=e.level-1;b>p;b--){var A=g[b].maxBitrate;if((L=E?u*A/(6.4*E):u*A/v)<S)break}L>=T||(l.logger.warn("Fragment "+e.sn+(t?" part "+t.index:"")+" of level "+e.level+" is loading too slowly and will cause an underbuffer; aborting and switching to level "+b+"\n Current BW estimate: "+((0,a.isFiniteNumber)(v)?(v/1024).toFixed(3):"Unknown")+" Kb/s\n Estimated load time for current fragment: "+T.toFixed(3)+" s\n Estimated load time for the next fragment: "+L.toFixed(3)+" s\n Time to underbuffer: "+S.toFixed(3)+" s"),r.nextLoadLevel=b,c&&this.bwEstimator.sample(h,o.loaded),this.clearTimer(),e.loader&&(this.fragCurrent=this.partCurrent=null,e.loader.abort()),r.trigger(s.Events.FRAG_LOAD_EMERGENCY_ABORTED,{frag:e,part:t,stats:o}))}}}}}},i.onFragLoaded=function(e,t){var r=t.frag,i=t.part;if(r.type===C.PlaylistLevelType.MAIN&&(0,a.isFiniteNumber)(r.sn)){var n=i?i.stats:r.stats,o=i?i.duration:r.duration;if(this.clearTimer(),this.lastLoadedFragLevel=r.level,this._nextAutoLevel=-1,this.hls.config.abrMaxWithRealBitrate){var l=this.hls.levels[r.level],u=(l.loaded?l.loaded.bytes:0)+n.loaded,d=(l.loaded?l.loaded.duration:0)+o;l.loaded={bytes:u,duration:d},l.realBitrate=Math.round(8*u/d)}if(r.bitrateTest){var h={stats:n,frag:r,part:i,id:r.type};this.onFragBuffered(s.Events.FRAG_BUFFERED,h)}}},i.onFragBuffered=function(e,t){var r=t.frag,i=t.part,n=i?i.stats:r.stats;if(!n.aborted&&r.type===C.PlaylistLevelType.MAIN&&"initSegment"!==r.sn){var a=n.parsing.end-n.loading.start;this.bwEstimator.sample(a,n.loaded),n.bwEstimate=this.bwEstimator.getEstimate(),r.bitrateTest?this.bitrateTestDelay=a/1e3:this.bitrateTestDelay=0}},i.onError=function(e,t){switch(t.details){case o.ErrorDetails.FRAG_LOAD_ERROR:case o.ErrorDetails.FRAG_LOAD_TIMEOUT:this.clearTimer()}},i.clearTimer=function(){self.clearInterval(this.timer),this.timer=void 0},i.getNextABRAutoLevel=function(){var e=this.fragCurrent,t=this.partCurrent,r=this.hls,i=r.maxAutoLevel,n=r.config,a=r.minAutoLevel,s=r.media,o=t?t.duration:e?e.duration:0,u=(s&&s.currentTime,s&&0!==s.playbackRate?Math.abs(s.playbackRate):1),d=this.bwEstimator?this.bwEstimator.getEstimate():n.abrEwmaDefaultEstimate,h=r.mainForwardBufferInfo,f=(h?h.len:0)/u,c=this.findBestLevel(d,a,i,f,n.abrBandWidthFactor,n.abrBandWidthUpFactor);if(c>=0)return c;l.logger.trace((f?"rebuffering expected":"buffer is empty")+", finding optimal quality level");var v=o?Math.min(o,n.maxStarvationDelay):n.maxStarvationDelay,g=n.abrBandWidthFactor,p=n.abrBandWidthUpFactor;if(!f){var m=this.bitrateTestDelay;m&&(v=(o?Math.min(o,n.maxLoadingDelay):n.maxLoadingDelay)-m,l.logger.trace("bitrate test took "+Math.round(1e3*m)+"ms, set first fragment max fetchDuration to "+Math.round(1e3*v)+" ms"),g=p=1)}return c=this.findBestLevel(d,a,i,f+v,g,p),Math.max(c,0)},i.findBestLevel=function(e,t,r,i,n,s){for(var o,u=this.fragCurrent,d=this.partCurrent,h=this.lastLoadedFragLevel,f=this.hls.levels,c=f[h],v=!(null==c||null===(o=c.details)||void 0===o||!o.live),g=null==c?void 0:c.codecSet,p=d?d.duration:u?u.duration:0,m=r;m>=t;m--){var y=f[m];if(y&&(!g||y.codecSet===g)){var E,T=y.details,S=(d?null==T?void 0:T.partTarget:null==T?void 0:T.averagetargetduration)||p;E=m<=h?n*e:s*e;var b=f[m].maxBitrate,L=b*S/E;if(l.logger.trace("level/adjustedbw/bitrate/avgDuration/maxFetchDuration/fetchDuration: "+m+"/"+Math.round(E)+"/"+b+"/"+S+"/"+i+"/"+L),E>b&&(0===L||!(0,a.isFiniteNumber)(L)||v&&!this.bitrateTestDelay||L<i))return m}}return-1},t=e,(r=[{key:"nextAutoLevel",get:function(){var e=this._nextAutoLevel,t=this.bwEstimator;if(-1!==e&&!t.canEstimate())return e;var r=this.getNextABRAutoLevel();return-1!==e&&this.hls.levels[r].loadError?e:(-1!==e&&(r=Math.min(e,r)),r)},set:function(e){this._nextAutoLevel=e}}])&&ut(t.prototype,r),Object.defineProperty(t,"prototype",{writable:!1}),e}();var ht=r(514),ft=r.n(ht),ct=function(){function e(e){this.buffers=void 0,this.queues={video:[],audio:[],audiovideo:[]},this.buffers=e}var t=e.prototype;return t.append=function(e,t){var r=this.queues[t];r.push(e),1===r.length&&this.buffers[t]&&this.executeNext(t)},t.insertAbort=function(e,t){this.queues[t].unshift(e),this.executeNext(t)},t.appendBlocker=function(e){var t,r=new Promise((function(e){t=e})),i={execute:t,onStart:function(){},onComplete:function(){},onError:function(){}};return this.append(i,e),r},t.executeNext=function(e){var t=this.buffers,r=this.queues,i=t[e],n=r[e];if(n.length){var a=n[0];try{a.execute()}catch(t){l.logger.warn("[buffer-operation-queue]: Unhandled exception executing the current operation"),a.onError(t),i&&i.updating||(n.shift(),this.executeNext(e))}}},t.shiftAndExecuteNext=function(e){this.queues[e].shift(),this.executeNext(e)},t.current=function(e){return this.queues[e][0]},e}(),vt=We(),gt=/([ha]vc.)(?:\.[^.,]+)+/,pt=function(){function e(e){var t=this;this.details=null,this._objectUrl=null,this.operationQueue=void 0,this.listeners=void 0,this.hls=void 0,this.bufferCodecEventsExpected=0,this._bufferCodecEventsTotal=0,this.media=null,this.mediaSource=null,this.appendError=0,this.tracks={},this.pendingTracks={},this.sourceBuffer=void 0,this._onMediaSourceOpen=function(){var e=t.hls,r=t.media,i=t.mediaSource;l.logger.log("[buffer-controller]: Media source opened"),r&&(t.updateMediaElementDuration(),e.trigger(s.Events.MEDIA_ATTACHED,{media:r})),i&&i.removeEventListener("sourceopen",t._onMediaSourceOpen),t.checkPendingTracks()},this._onMediaSourceClose=function(){l.logger.log("[buffer-controller]: Media source closed")},this._onMediaSourceEnded=function(){l.logger.log("[buffer-controller]: Media source ended")},this.hls=e,this._initSourceBuffer(),this.registerListeners()}var t=e.prototype;return t.hasSourceTypes=function(){return this.getSourceBufferTypes().length>0||Object.keys(this.pendingTracks).length>0},t.destroy=function(){this.unregisterListeners(),this.details=null},t.registerListeners=function(){var e=this.hls;e.on(s.Events.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(s.Events.MANIFEST_PARSED,this.onManifestParsed,this),e.on(s.Events.BUFFER_RESET,this.onBufferReset,this),e.on(s.Events.BUFFER_APPENDING,this.onBufferAppending,this),e.on(s.Events.BUFFER_CODECS,this.onBufferCodecs,this),e.on(s.Events.BUFFER_EOS,this.onBufferEos,this),e.on(s.Events.BUFFER_FLUSHING,this.onBufferFlushing,this),e.on(s.Events.LEVEL_UPDATED,this.onLevelUpdated,this),e.on(s.Events.FRAG_PARSED,this.onFragParsed,this),e.on(s.Events.FRAG_CHANGED,this.onFragChanged,this)},t.unregisterListeners=function(){var e=this.hls;e.off(s.Events.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(s.Events.MANIFEST_PARSED,this.onManifestParsed,this),e.off(s.Events.BUFFER_RESET,this.onBufferReset,this),e.off(s.Events.BUFFER_APPENDING,this.onBufferAppending,this),e.off(s.Events.BUFFER_CODECS,this.onBufferCodecs,this),e.off(s.Events.BUFFER_EOS,this.onBufferEos,this),e.off(s.Events.BUFFER_FLUSHING,this.onBufferFlushing,this),e.off(s.Events.LEVEL_UPDATED,this.onLevelUpdated,this),e.off(s.Events.FRAG_PARSED,this.onFragParsed,this),e.off(s.Events.FRAG_CHANGED,this.onFragChanged,this)},t._initSourceBuffer=function(){this.sourceBuffer={},this.operationQueue=new ct(this.sourceBuffer),this.listeners={audio:[],video:[],audiovideo:[]}},t.onManifestParsed=function(e,t){var r=2;(t.audio&&!t.video||!t.altAudio)&&(r=1),this.bufferCodecEventsExpected=this._bufferCodecEventsTotal=r,this.details=null,l.logger.log(this.bufferCodecEventsExpected+" bufferCodec event(s) expected")},t.onMediaAttaching=function(e,t){var r=this.media=t.media;if(r&&vt){var i=this.mediaSource=new vt;i.addEventListener("sourceopen",this._onMediaSourceOpen),i.addEventListener("sourceended",this._onMediaSourceEnded),i.addEventListener("sourceclose",this._onMediaSourceClose),r.src=self.URL.createObjectURL(i),this._objectUrl=r.src}},t.onMediaDetaching=function(){var e=this.media,t=this.mediaSource,r=this._objectUrl;if(t){if(l.logger.log("[buffer-controller]: media source detaching"),"open"===t.readyState)try{t.endOfStream()}catch(e){l.logger.warn("[buffer-controller]: onMediaDetaching: "+e.message+" while calling endOfStream")}this.onBufferReset(),t.removeEventListener("sourceopen",this._onMediaSourceOpen),t.removeEventListener("sourceended",this._onMediaSourceEnded),t.removeEventListener("sourceclose",this._onMediaSourceClose),e&&(r&&self.URL.revokeObjectURL(r),e.src===r?(e.removeAttribute("src"),e.load()):l.logger.warn("[buffer-controller]: media.src was changed by a third party - skip cleanup")),this.mediaSource=null,this.media=null,this._objectUrl=null,this.bufferCodecEventsExpected=this._bufferCodecEventsTotal,this.pendingTracks={},this.tracks={}}this.hls.trigger(s.Events.MEDIA_DETACHED,void 0)},t.onBufferReset=function(){var e=this;this.getSourceBufferTypes().forEach((function(t){var r=e.sourceBuffer[t];try{r&&(e.removeBufferListeners(t),e.mediaSource&&e.mediaSource.removeSourceBuffer(r),e.sourceBuffer[t]=void 0)}catch(e){l.logger.warn("[buffer-controller]: Failed to reset the "+t+" buffer",e)}})),this._initSourceBuffer()},t.onBufferCodecs=function(e,t){var r=this,i=this.getSourceBufferTypes().length;Object.keys(t).forEach((function(e){if(i){var n=r.tracks[e];if(n&&"function"==typeof n.buffer.changeType){var a=t[e],s=a.id,o=a.codec,u=a.levelCodec,d=a.container,h=a.metadata,f=(n.levelCodec||n.codec).replace(gt,"$1"),c=(u||o).replace(gt,"$1");if(f!==c){var v=d+";codecs="+(u||o);r.appendChangeType(e,v),l.logger.log("[buffer-controller]: switching codec "+f+" to "+c),r.tracks[e]={buffer:n.buffer,codec:o,container:d,levelCodec:u,metadata:h,id:s}}}}else r.pendingTracks[e]=t[e]})),i||(this.bufferCodecEventsExpected=Math.max(this.bufferCodecEventsExpected-1,0),this.mediaSource&&"open"===this.mediaSource.readyState&&this.checkPendingTracks())},t.appendChangeType=function(e,t){var r=this,i=this.operationQueue,n={execute:function(){var n=r.sourceBuffer[e];n&&(l.logger.log("[buffer-controller]: changing "+e+" sourceBuffer type to "+t),n.changeType(t)),i.shiftAndExecuteNext(e)},onStart:function(){},onComplete:function(){},onError:function(t){l.logger.warn("[buffer-controller]: Failed to change "+e+" SourceBuffer type",t)}};i.append(n,e)},t.onBufferAppending=function(e,t){var r=this,i=this.hls,n=this.operationQueue,a=this.tracks,u=t.data,d=t.type,h=t.frag,f=t.part,c=t.chunkMeta,v=c.buffering[d],g=self.performance.now();v.start=g;var p=h.stats.buffering,m=f?f.stats.buffering:null;0===p.start&&(p.start=g),m&&0===m.start&&(m.start=g);var y=a.audio,E="audio"===d&&1===c.id&&"audio/mpeg"===(null==y?void 0:y.container),T={execute:function(){if(v.executeStart=self.performance.now(),E){var e=r.sourceBuffer[d];if(e){var t=h.start-e.timestampOffset;Math.abs(t)>=.1&&(l.logger.log("[buffer-controller]: Updating audio SourceBuffer timestampOffset to "+h.start+" (delta: "+t+") sn: "+h.sn+")"),e.timestampOffset=h.start)}}r.appendExecutor(u,d)},onStart:function(){},onComplete:function(){var e=self.performance.now();v.executeEnd=v.end=e,0===p.first&&(p.first=e),m&&0===m.first&&(m.first=e);var t=r.sourceBuffer,i={};for(var n in t)i[n]=ce.getBuffered(t[n]);r.appendError=0,r.hls.trigger(s.Events.BUFFER_APPENDED,{type:d,frag:h,part:f,chunkMeta:c,parent:h.type,timeRanges:i})},onError:function(e){l.logger.error("[buffer-controller]: Error encountered while trying to append to the "+d+" SourceBuffer",e);var t={type:o.ErrorTypes.MEDIA_ERROR,parent:h.type,details:o.ErrorDetails.BUFFER_APPEND_ERROR,err:e,fatal:!1};e.code===DOMException.QUOTA_EXCEEDED_ERR?t.details=o.ErrorDetails.BUFFER_FULL_ERROR:(r.appendError++,t.details=o.ErrorDetails.BUFFER_APPEND_ERROR,r.appendError>i.config.appendErrorMaxRetry&&(l.logger.error("[buffer-controller]: Failed "+i.config.appendErrorMaxRetry+" times to append segment in sourceBuffer"),t.fatal=!0,i.stopLoad())),i.trigger(s.Events.ERROR,t)}};n.append(T,d)},t.onBufferFlushing=function(e,t){var r=this,i=this.operationQueue,n=function(e){return{execute:r.removeExecutor.bind(r,e,t.startOffset,t.endOffset),onStart:function(){},onComplete:function(){r.hls.trigger(s.Events.BUFFER_FLUSHED,{type:e})},onError:function(t){l.logger.warn("[buffer-controller]: Failed to remove from "+e+" SourceBuffer",t)}}};t.type?i.append(n(t.type),t.type):this.getSourceBufferTypes().forEach((function(e){i.append(n(e),e)}))},t.onFragParsed=function(e,t){var r=this,i=t.frag,n=t.part,a=[],o=n?n.elementaryStreams:i.elementaryStreams;o[p.ElementaryStreamTypes.AUDIOVIDEO]?a.push("audiovideo"):(o[p.ElementaryStreamTypes.AUDIO]&&a.push("audio"),o[p.ElementaryStreamTypes.VIDEO]&&a.push("video")),0===a.length&&l.logger.warn("Fragments must have at least one ElementaryStreamType set. type: "+i.type+" level: "+i.level+" sn: "+i.sn),this.blockBuffers((function(){var e=self.performance.now();i.stats.buffering.end=e,n&&(n.stats.buffering.end=e);var t=n?n.stats:i.stats;r.hls.trigger(s.Events.FRAG_BUFFERED,{frag:i,part:n,stats:t,id:i.type})}),a)},t.onFragChanged=function(e,t){this.flushBackBuffer()},t.onBufferEos=function(e,t){var r=this;this.getSourceBufferTypes().reduce((function(e,i){var n=r.sourceBuffer[i];return t.type&&t.type!==i||n&&!n.ended&&(n.ended=!0,l.logger.log("[buffer-controller]: "+i+" sourceBuffer now EOS")),e&&!(n&&!n.ended)}),!0)&&this.blockBuffers((function(){var e=r.mediaSource;e&&"open"===e.readyState&&e.endOfStream()}))},t.onLevelUpdated=function(e,t){var r=t.details;r.fragments.length&&(this.details=r,this.getSourceBufferTypes().length?this.blockBuffers(this.updateMediaElementDuration.bind(this)):this.updateMediaElementDuration())},t.flushBackBuffer=function(){var e=this.hls,t=this.details,r=this.media,i=this.sourceBuffer;if(r&&null!==t){var n=this.getSourceBufferTypes();if(n.length){var o=t.live&&null!==e.config.liveBackBufferLength?e.config.liveBackBufferLength:e.config.backBufferLength;if((0,a.isFiniteNumber)(o)&&!(o<0)){var l=r.currentTime,u=t.levelTargetDuration,d=Math.max(o,u),h=Math.floor(l/u)*u-d;n.forEach((function(r){var n=i[r];if(n){var a=ce.getBuffered(n);a.length>0&&h>a.start(0)&&(e.trigger(s.Events.BACK_BUFFER_REACHED,{bufferEnd:h}),t.live&&e.trigger(s.Events.LIVE_BACK_BUFFER_REACHED,{bufferEnd:h}),e.trigger(s.Events.BUFFER_FLUSHING,{startOffset:0,endOffset:h,type:r}))}}))}}}},t.updateMediaElementDuration=function(){if(this.details&&this.media&&this.mediaSource&&"open"===this.mediaSource.readyState){var e=this.details,t=this.hls,r=this.media,i=this.mediaSource,n=e.fragments[0].start+e.totalduration,s=r.duration,o=(0,a.isFiniteNumber)(i.duration)?i.duration:0;e.live&&t.config.liveDurationInfinity?(l.logger.log("[buffer-controller]: Media Source duration is set to Infinity"),i.duration=1/0,this.updateSeekableRange(e)):(n>o&&n>s||!(0,a.isFiniteNumber)(s))&&(l.logger.log("[buffer-controller]: Updating Media Source duration to "+n.toFixed(3)),i.duration=n)}},t.updateSeekableRange=function(e){var t=this.mediaSource,r=e.fragments;if(r.length&&e.live&&null!=t&&t.setLiveSeekableRange){var i=Math.max(0,r[0].start),n=Math.max(i,i+e.totalduration);t.setLiveSeekableRange(i,n)}},t.checkPendingTracks=function(){var e=this.bufferCodecEventsExpected,t=this.operationQueue,r=this.pendingTracks,i=Object.keys(r).length;if(i&&!e||2===i){this.createSourceBuffers(r),this.pendingTracks={};var n=this.getSourceBufferTypes();if(0===n.length)return void this.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.MEDIA_ERROR,details:o.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR,fatal:!0,reason:"could not create source buffer for media codec(s)"});n.forEach((function(e){t.executeNext(e)}))}},t.createSourceBuffers=function(e){var t=this.sourceBuffer,r=this.mediaSource;if(!r)throw Error("createSourceBuffers called when mediaSource was null");var i=0;for(var n in e)if(!t[n]){var a=e[n];if(!a)throw Error("source buffer exists for track "+n+", however track does not");var u=a.levelCodec||a.codec,d=a.container+";codecs="+u;l.logger.log("[buffer-controller]: creating sourceBuffer("+d+")");try{var h=t[n]=r.addSourceBuffer(d),f=n;this.addBufferListener(f,"updatestart",this._onSBUpdateStart),this.addBufferListener(f,"updateend",this._onSBUpdateEnd),this.addBufferListener(f,"error",this._onSBUpdateError),this.tracks[n]={buffer:h,codec:u,container:a.container,levelCodec:a.levelCodec,metadata:a.metadata,id:a.id},i++}catch(e){l.logger.error("[buffer-controller]: error while trying to add sourceBuffer: "+e.message),this.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.MEDIA_ERROR,details:o.ErrorDetails.BUFFER_ADD_CODEC_ERROR,fatal:!1,error:e,mimeType:d})}}i&&this.hls.trigger(s.Events.BUFFER_CREATED,{tracks:this.tracks})},t._onSBUpdateStart=function(e){this.operationQueue.current(e).onStart()},t._onSBUpdateEnd=function(e){var t=this.operationQueue;t.current(e).onComplete(),t.shiftAndExecuteNext(e)},t._onSBUpdateError=function(e,t){l.logger.error("[buffer-controller]: "+e+" SourceBuffer error",t),this.hls.trigger(s.Events.ERROR,{type:o.ErrorTypes.MEDIA_ERROR,details:o.ErrorDetails.BUFFER_APPENDING_ERROR,fatal:!1});var r=this.operationQueue.current(e);r&&r.onError(t)},t.removeExecutor=function(e,t,r){var i=this.media,n=this.mediaSource,s=this.operationQueue,o=this.sourceBuffer[e];if(!i||!n||!o)return l.logger.warn("[buffer-controller]: Attempting to remove from the "+e+" SourceBuffer, but it does not exist"),void s.shiftAndExecuteNext(e);var u=(0,a.isFiniteNumber)(i.duration)?i.duration:1/0,d=(0,a.isFiniteNumber)(n.duration)?n.duration:1/0,h=Math.max(0,t),f=Math.min(r,u,d);f>h?(l.logger.log("[buffer-controller]: Removing ["+h+","+f+"] from the "+e+" SourceBuffer"),o.remove(h,f)):s.shiftAndExecuteNext(e)},t.appendExecutor=function(e,t){var r=this.operationQueue,i=this.sourceBuffer[t];if(!i)return l.logger.warn("[buffer-controller]: Attempting to append to the "+t+" SourceBuffer, but it does not exist"),void r.shiftAndExecuteNext(t);i.ended=!1,i.appendBuffer(e)},t.blockBuffers=function(e,t){var r=this;if(void 0===t&&(t=this.getSourceBufferTypes()),!t.length)return l.logger.log("[buffer-controller]: Blocking operation requested, but no SourceBuffers exist"),void Promise.resolve().then(e);var i=this.operationQueue,n=t.map((function(e){return i.appendBlocker(e)}));Promise.all(n).then((function(){e(),t.forEach((function(e){var t=r.sourceBuffer[e];t&&t.updating||i.shiftAndExecuteNext(e)}))}))},t.getSourceBufferTypes=function(){return Object.keys(this.sourceBuffer)},t.addBufferListener=function(e,t,r){var i=this.sourceBuffer[e];if(i){var n=r.bind(this,e);this.listeners[e].push({event:t,listener:n}),i.addEventListener(t,n)}},t.removeBufferListeners=function(e){var t=this.sourceBuffer[e];t&&this.listeners[e].forEach((function(e){t.removeEventListener(e.event,e.listener)}))},e}();function mt(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}const yt=function(){function e(e){this.autoLevelCapping=void 0,this.firstLevel=void 0,this.media=void 0,this.restrictedLevels=void 0,this.timer=void 0,this.hls=void 0,this.streamController=void 0,this.clientRect=void 0,this.hls=e,this.autoLevelCapping=Number.POSITIVE_INFINITY,this.firstLevel=-1,this.media=null,this.restrictedLevels=[],this.timer=void 0,this.clientRect=null,this.registerListeners()}var t,r,i=e.prototype;return i.setStreamController=function(e){this.streamController=e},i.destroy=function(){this.unregisterListener(),this.hls.config.capLevelToPlayerSize&&this.stopCapping(),this.media=null,this.clientRect=null,this.hls=this.streamController=null},i.registerListeners=function(){var e=this.hls;e.on(s.Events.FPS_DROP_LEVEL_CAPPING,this.onFpsDropLevelCapping,this),e.on(s.Events.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(s.Events.MANIFEST_PARSED,this.onManifestParsed,this),e.on(s.Events.BUFFER_CODECS,this.onBufferCodecs,this),e.on(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this)},i.unregisterListener=function(){var e=this.hls;e.off(s.Events.FPS_DROP_LEVEL_CAPPING,this.onFpsDropLevelCapping,this),e.off(s.Events.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(s.Events.MANIFEST_PARSED,this.onManifestParsed,this),e.off(s.Events.BUFFER_CODECS,this.onBufferCodecs,this),e.off(s.Events.MEDIA_DETACHING,this.onMediaDetaching,this)},i.onFpsDropLevelCapping=function(t,r){e.isLevelAllowed(r.droppedLevel,this.restrictedLevels)&&this.restrictedLevels.push(r.droppedLevel)},i.onMediaAttaching=function(e,t){this.media=t.media instanceof HTMLVideoElement?t.media:null},i.onManifestParsed=function(e,t){var r=this.hls;this.restrictedLevels=[],this.firstLevel=t.firstLevel,r.config.capLevelToPlayerSize&&t.video&&this.startCapping()},i.onBufferCodecs=function(e,t){this.hls.config.capLevelToPlayerSize&&t.video&&this.startCapping()},i.onMediaDetaching=function(){this.stopCapping()},i.detectPlayerSize=function(){if(this.media&&this.mediaHeight>0&&this.mediaWidth>0){var e=this.hls.levels;if(e.length){var t=this.hls;t.autoLevelCapping=this.getMaxLevel(e.length-1),t.autoLevelCapping>this.autoLevelCapping&&this.streamController&&this.streamController.nextLevelSwitch(),this.autoLevelCapping=t.autoLevelCapping}}},i.getMaxLevel=function(t){var r=this,i=this.hls.levels;if(!i.length)return-1;var n=i.filter((function(i,n){return e.isLevelAllowed(n,r.restrictedLevels)&&n<=t}));return this.clientRect=null,e.getMaxLevelByMediaSize(n,this.mediaWidth,this.mediaHeight)},i.startCapping=function(){this.timer||(this.autoLevelCapping=Number.POSITIVE_INFINITY,this.hls.firstLevel=this.getMaxLevel(this.firstLevel),self.clearInterval(this.timer),this.timer=self.setInterval(this.detectPlayerSize.bind(this),1e3),this.detectPlayerSize())},i.stopCapping=function(){this.restrictedLevels=[],this.firstLevel=-1,this.autoLevelCapping=Number.POSITIVE_INFINITY,this.timer&&(self.clearInterval(this.timer),this.timer=void 0)},i.getDimensions=function(){if(this.clientRect)return this.clientRect;var e=this.media,t={width:0,height:0};if(e){var r=e.getBoundingClientRect();t.width=r.width,t.height=r.height,t.width||t.height||(t.width=r.right-r.left||e.width||0,t.height=r.bottom-r.top||e.height||0)}return this.clientRect=t,t},e.isLevelAllowed=function(e,t){return void 0===t&&(t=[]),-1===t.indexOf(e)},e.getMaxLevelByMediaSize=function(e,t,r){if(!e||!e.length)return-1;for(var i,n,a=e.length-1,s=0;s<e.length;s+=1){var o=e[s];if((o.width>=t||o.height>=r)&&(i=o,!(n=e[s+1])||i.width!==n.width||i.height!==n.height)){a=s;break}}return a},t=e,(r=[{key:"mediaWidth",get:function(){return this.getDimensions().width*this.contentScaleFactor}},{key:"mediaHeight",get:function(){return this.getDimensions().height*this.contentScaleFactor}},{key:"contentScaleFactor",get:function(){var e=1;if(!this.hls.config.ignoreDevicePixelRatio)try{e=self.devicePixelRatio}catch(e){}return e}}])&&mt(t.prototype,r),Object.defineProperty(t,"prototype",{writable:!1}),e}(),Et=function(){function e(e){this.hls=void 0,this.isVideoPlaybackQualityAvailable=!1,this.timer=void 0,this.media=null,this.lastTime=void 0,this.lastDroppedFrames=0,this.lastDecodedFrames=0,this.streamController=void 0,this.hls=e,this.registerListeners()}var t=e.prototype;return t.setStreamController=function(e){this.streamController=e},t.registerListeners=function(){this.hls.on(s.Events.MEDIA_ATTACHING,this.onMediaAttaching,this)},t.unregisterListeners=function(){this.hls.off(s.Events.MEDIA_ATTACHING,this.onMediaAttaching)},t.destroy=function(){this.timer&&clearInterval(this.timer),this.unregisterListeners(),this.isVideoPlaybackQualityAvailable=!1,this.media=null},t.onMediaAttaching=function(e,t){var r=this.hls.config;if(r.capLevelOnFPSDrop){var i=t.media instanceof self.HTMLVideoElement?t.media:null;this.media=i,i&&"function"==typeof i.getVideoPlaybackQuality&&(this.isVideoPlaybackQualityAvailable=!0),self.clearInterval(this.timer),this.timer=self.setInterval(this.checkFPSInterval.bind(this),r.fpsDroppedMonitoringPeriod)}},t.checkFPS=function(e,t,r){var i=performance.now();if(t){if(this.lastTime){var n=i-this.lastTime,a=r-this.lastDroppedFrames,o=t-this.lastDecodedFrames,u=1e3*a/n,d=this.hls;if(d.trigger(s.Events.FPS_DROP,{currentDropped:a,currentDecoded:o,totalDroppedFrames:r}),u>0&&a>d.config.fpsDroppedMonitoringThreshold*o){var h=d.currentLevel;l.logger.warn("drop FPS ratio greater than max allowed value for currentLevel: "+h),h>0&&(-1===d.autoLevelCapping||d.autoLevelCapping>=h)&&(h-=1,d.trigger(s.Events.FPS_DROP_LEVEL_CAPPING,{level:h,droppedLevel:d.currentLevel}),d.autoLevelCapping=h,this.streamController.nextLevelSwitch())}}this.lastTime=i,this.lastDroppedFrames=r,this.lastDecodedFrames=t}},t.checkFPSInterval=function(){var e=this.media;if(e)if(this.isVideoPlaybackQualityAvailable){var t=e.getVideoPlaybackQuality();this.checkFPS(e,t.totalVideoFrames,t.droppedVideoFrames)}else this.checkFPS(e,e.webkitDecodedFrameCount,e.webkitDroppedFrameCount)},e}();var Tt=r(408),St=/^age:\s*[\d.]+\s*$/m;const bt=function(){function e(e){this.xhrSetup=void 0,this.requestTimeout=void 0,this.retryTimeout=void 0,this.retryDelay=void 0,this.config=null,this.callbacks=null,this.context=void 0,this.loader=null,this.stats=void 0,this.xhrSetup=e?e.xhrSetup:null,this.stats=new Tt.LoadStats,this.retryDelay=0}var t=e.prototype;return t.destroy=function(){this.callbacks=null,this.abortInternal(),this.loader=null,this.config=null},t.abortInternal=function(){var e=this.loader;self.clearTimeout(this.requestTimeout),self.clearTimeout(this.retryTimeout),e&&(e.onreadystatechange=null,e.onprogress=null,4!==e.readyState&&(this.stats.aborted=!0,e.abort()))},t.abort=function(){var e;this.abortInternal(),null!==(e=this.callbacks)&&void 0!==e&&e.onAbort&&this.callbacks.onAbort(this.stats,this.context,this.loader)},t.load=function(e,t,r){if(this.stats.loading.start)throw new Error("Loader can only be used once.");this.stats.loading.start=self.performance.now(),this.context=e,this.config=t,this.callbacks=r,this.retryDelay=t.retryDelay,this.loadInternal()},t.loadInternal=function(){var e=this.config,t=this.context;if(e){var r=this.loader=new self.XMLHttpRequest,i=this.stats;i.loading.first=0,i.loaded=0;var n=this.xhrSetup;try{if(n)try{n(r,t.url)}catch(e){r.open("GET",t.url,!0),n(r,t.url)}r.readyState||r.open("GET",t.url,!0);var a=this.context.headers;if(a)for(var s in a)r.setRequestHeader(s,a[s])}catch(e){return void this.callbacks.onError({code:r.status,text:e.message},t,r)}t.rangeEnd&&r.setRequestHeader("Range","bytes="+t.rangeStart+"-"+(t.rangeEnd-1)),r.onreadystatechange=this.readystatechange.bind(this),r.onprogress=this.loadprogress.bind(this),r.responseType=t.responseType,self.clearTimeout(this.requestTimeout),this.requestTimeout=self.setTimeout(this.loadtimeout.bind(this),e.timeout),r.send()}},t.readystatechange=function(){var e=this.context,t=this.loader,r=this.stats;if(e&&t){var i=t.readyState,n=this.config;if(!r.aborted&&i>=2)if(self.clearTimeout(this.requestTimeout),0===r.loading.first&&(r.loading.first=Math.max(self.performance.now(),r.loading.start)),4===i){t.onreadystatechange=null,t.onprogress=null;var a=t.status,s="arraybuffer"===t.responseType;if(a>=200&&a<300&&(s&&t.response||null!==t.responseText)){var o,u;if(r.loading.end=Math.max(self.performance.now(),r.loading.first),u=s?(o=t.response).byteLength:(o=t.responseText).length,r.loaded=r.total=u,!this.callbacks)return;var d=this.callbacks.onProgress;if(d&&d(r,e,o,t),!this.callbacks)return;var h={url:t.responseURL,data:o};this.callbacks.onSuccess(h,r,e,t)}else r.retry>=n.maxRetry||a>=400&&a<499?(l.logger.error(a+" while loading "+e.url),this.callbacks.onError({code:a,text:t.statusText},e,t)):(l.logger.warn(a+" while loading "+e.url+", retrying in "+this.retryDelay+"..."),this.abortInternal(),this.loader=null,self.clearTimeout(this.retryTimeout),this.retryTimeout=self.setTimeout(this.loadInternal.bind(this),this.retryDelay),this.retryDelay=Math.min(2*this.retryDelay,n.maxRetryDelay),r.retry++)}else self.clearTimeout(this.requestTimeout),this.requestTimeout=self.setTimeout(this.loadtimeout.bind(this),n.timeout)}},t.loadtimeout=function(){l.logger.warn("timeout while loading "+this.context.url);var e=this.callbacks;e&&(this.abortInternal(),e.onTimeout(this.stats,this.context,this.loader))},t.loadprogress=function(e){var t=this.stats;t.loaded=e.loaded,e.lengthComputable&&(t.total=e.total)},t.getCacheAge=function(){var e=null;if(this.loader&&St.test(this.loader.getAllResponseHeaders())){var t=this.loader.getResponseHeader("age");e=t?parseFloat(t):null}return e},e}();var Lt=function(){function e(){this.chunks=[],this.dataLength=0}var t=e.prototype;return t.push=function(e){this.chunks.push(e),this.dataLength+=e.length},t.flush=function(){var e,t=this.chunks,r=this.dataLength;return t.length?(e=1===t.length?t[0]:function(e,t){for(var r=new Uint8Array(t),i=0,n=0;n<e.length;n++){var a=e[n];r.set(a,i),i+=a.length}return r}(t,r),this.reset(),e):new Uint8Array(0)},t.reset=function(){this.chunks.length=0,this.dataLength=0},e}();function At(e){var t="function"==typeof Map?new Map:void 0;return At=function(e){if(null===e||(r=e,-1===Function.toString.call(r).indexOf("[native code]")))return e;var r;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,i)}function i(){return Dt(e,arguments,_t(this).constructor)}return i.prototype=Object.create(e.prototype,{constructor:{value:i,enumerable:!1,writable:!0,configurable:!0}}),kt(i,e)},At(e)}function Dt(e,t,r){return Dt=Rt()?Reflect.construct.bind():function(e,t,r){var i=[null];i.push.apply(i,t);var n=new(Function.bind.apply(e,i));return r&&kt(n,r.prototype),n},Dt.apply(null,arguments)}function Rt(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(e){return!1}}function kt(e,t){return kt=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},kt(e,t)}function _t(e){return _t=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},_t(e)}function xt(){return xt=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},xt.apply(this,arguments)}var It=function(){function e(e){this.fetchSetup=void 0,this.requestTimeout=void 0,this.request=void 0,this.response=void 0,this.controller=void 0,this.context=void 0,this.config=null,this.callbacks=null,this.stats=void 0,this.loader=null,this.fetchSetup=e.fetchSetup||wt,this.controller=new self.AbortController,this.stats=new Tt.LoadStats}var t=e.prototype;return t.destroy=function(){this.loader=this.callbacks=null,this.abortInternal()},t.abortInternal=function(){var e=this.response;e&&e.ok||(this.stats.aborted=!0,this.controller.abort())},t.abort=function(){var e;this.abortInternal(),null!==(e=this.callbacks)&&void 0!==e&&e.onAbort&&this.callbacks.onAbort(this.stats,this.context,this.response)},t.load=function(e,t,r){var i=this,n=this.stats;if(n.loading.start)throw new Error("Loader can only be used once.");n.loading.start=self.performance.now();var s=function(e,t){var r={method:"GET",mode:"cors",credentials:"same-origin",signal:t,headers:new self.Headers(xt({},e.headers))};return e.rangeEnd&&r.headers.set("Range","bytes="+e.rangeStart+"-"+String(e.rangeEnd-1)),r}(e,this.controller.signal),o=r.onProgress,l="arraybuffer"===e.responseType,u=l?"byteLength":"length";this.context=e,this.config=t,this.callbacks=r,this.request=this.fetchSetup(e,s),self.clearTimeout(this.requestTimeout),this.requestTimeout=self.setTimeout((function(){i.abortInternal(),r.onTimeout(n,e,i.response)}),t.timeout),self.fetch(this.request).then((function(r){if(i.response=i.loader=r,!r.ok){var s=r.status,u=r.statusText;throw new Ct(u||"fetch, bad network response",s,r)}return n.loading.first=Math.max(self.performance.now(),n.loading.start),n.total=parseInt(r.headers.get("Content-Length")||"0"),o&&(0,a.isFiniteNumber)(t.highWaterMark)?i.loadProgressively(r,n,e,t.highWaterMark,o):l?r.arrayBuffer():r.text()})).then((function(s){var l=i.response;self.clearTimeout(i.requestTimeout),n.loading.end=Math.max(self.performance.now(),n.loading.first);var d=s[u];d&&(n.loaded=n.total=d);var h={url:l.url,data:s};o&&!(0,a.isFiniteNumber)(t.highWaterMark)&&o(n,e,s,l),r.onSuccess(h,n,e,l)})).catch((function(t){if(self.clearTimeout(i.requestTimeout),!n.aborted){var a=t&&t.code||0,s=t?t.message:null;r.onError({code:a,text:s},e,t?t.details:null)}}))},t.getCacheAge=function(){var e=null;if(this.response){var t=this.response.headers.get("age");e=t?parseFloat(t):null}return e},t.loadProgressively=function(e,t,r,i,n){void 0===i&&(i=0);var a=new Lt,s=e.body.getReader();return function o(){return s.read().then((function(s){if(s.done)return a.dataLength&&n(t,r,a.flush(),e),Promise.resolve(new ArrayBuffer(0));var l=s.value,u=l.length;return t.loaded+=u,u<i||a.dataLength?(a.push(l),a.dataLength>=i&&n(t,r,a.flush(),e)):n(t,r,l,e),o()})).catch((function(){return Promise.reject()}))}()},e}();function wt(e,t){return new self.Request(e.url,t)}var Ct=function(e){var t,r;function i(t,r,i){var n;return(n=e.call(this,t)||this).code=void 0,n.details=void 0,n.code=r,n.details=i,n}return r=e,(t=i).prototype=Object.create(r.prototype),t.prototype.constructor=t,kt(t,r),i}(At(Error));const Pt=It;var Ot;!function(e){e.WIDEVINE="com.widevine.alpha",e.PLAYREADY="com.microsoft.playready"}(Ot||(Ot={}));var Ft="undefined"!=typeof self&&self.navigator&&self.navigator.requestMediaKeySystemAccess?self.navigator.requestMediaKeySystemAccess.bind(self.navigator):null;function Mt(){return Mt=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},Mt.apply(this,arguments)}function Nt(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);t&&(i=i.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,i)}return r}function Ut(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?Nt(Object(r),!0).forEach((function(t){Bt(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):Nt(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function Bt(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}var Gt=Ut(Ut({autoStartLoad:!0,startPosition:-1,defaultAudioCodec:void 0,debug:!1,capLevelOnFPSDrop:!1,capLevelToPlayerSize:!1,ignoreDevicePixelRatio:!1,initialLiveManifestSize:1,maxBufferLength:30,backBufferLength:1/0,maxBufferSize:6e7,maxBufferHole:.1,highBufferWatchdogPeriod:2,nudgeOffset:.1,nudgeMaxRetry:3,maxFragLookUpTolerance:.25,liveSyncDurationCount:3,liveMaxLatencyDurationCount:1/0,liveSyncDuration:void 0,liveMaxLatencyDuration:void 0,maxLiveSyncPlaybackRate:1,liveDurationInfinity:!1,liveBackBufferLength:null,maxMaxBufferLength:600,enableWorker:!0,enableSoftwareAES:!0,manifestLoadingTimeOut:1e4,manifestLoadingMaxRetry:1,manifestLoadingRetryDelay:1e3,manifestLoadingMaxRetryTimeout:64e3,startLevel:void 0,levelLoadingTimeOut:1e4,levelLoadingMaxRetry:4,levelLoadingRetryDelay:1e3,levelLoadingMaxRetryTimeout:64e3,fragLoadingTimeOut:2e4,fragLoadingMaxRetry:6,fragLoadingRetryDelay:1e3,fragLoadingMaxRetryTimeout:64e3,startFragPrefetch:!1,fpsDroppedMonitoringPeriod:5e3,fpsDroppedMonitoringThreshold:.2,appendErrorMaxRetry:3,loader:bt,fLoader:void 0,pLoader:void 0,xhrSetup:void 0,licenseXhrSetup:void 0,licenseResponseCallback:void 0,abrController:dt,bufferController:pt,capLevelController:yt,fpsController:Et,stretchShortVideoTrack:!1,maxAudioFramesDrift:1,forceKeyFrameOnDiscontinuity:!0,abrEwmaFastLive:3,abrEwmaSlowLive:9,abrEwmaFastVoD:3,abrEwmaSlowVoD:9,abrEwmaDefaultEstimate:5e5,abrBandWidthFactor:.95,abrBandWidthUpFactor:.7,abrMaxWithRealBitrate:!1,maxStarvationDelay:4,maxLoadingDelay:4,minAutoBitrate:0,emeEnabled:!1,widevineLicenseUrl:void 0,drmSystemOptions:{},requestMediaKeySystemAccessFunc:Ft,testBandwidth:!0,progressive:!1,lowLatencyMode:!0,cmcd:void 0,enableDateRangeMetadataCues:!0,enableEmsgMetadataCues:!0,enableID3MetadataCues:!0},{cueHandler:ft(),enableWebVTT:!1,enableIMSC1:!1,enableCEA708Captions:!1,captionsTextTrack1Label:"English",captionsTextTrack1LanguageCode:"en",captionsTextTrack2Label:"Spanish",captionsTextTrack2LanguageCode:"es",captionsTextTrack3Label:"Unknown CC",captionsTextTrack3LanguageCode:"",captionsTextTrack4Label:"Unknown CC",captionsTextTrack4LanguageCode:"",renderTextTracksNatively:!0}),{},{subtitleStreamController:void 0,subtitleTrackController:void 0,timelineController:void 0,audioStreamController:void 0,audioTrackController:void 0,emeController:void 0,cmcdController:void 0});function Vt(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}var Ht=function(){function e(t){void 0===t&&(t={}),this.config=void 0,this.userConfig=void 0,this.coreComponents=void 0,this.networkControllers=void 0,this._emitter=new et.EventEmitter,this._autoLevelCapping=void 0,this.abrController=void 0,this.bufferController=void 0,this.capLevelController=void 0,this.latencyController=void 0,this.levelController=void 0,this.streamController=void 0,this.audioTrackController=void 0,this.subtitleTrackController=void 0,this.emeController=void 0,this.cmcdController=void 0,this._media=null,this.url=null;var r=this.config=function(e,t){if((t.liveSyncDurationCount||t.liveMaxLatencyDurationCount)&&(t.liveSyncDuration||t.liveMaxLatencyDuration))throw new Error("Illegal hls.js config: don't mix up liveSyncDurationCount/liveMaxLatencyDurationCount and liveSyncDuration/liveMaxLatencyDuration");if(void 0!==t.liveMaxLatencyDurationCount&&(void 0===t.liveSyncDurationCount||t.liveMaxLatencyDurationCount<=t.liveSyncDurationCount))throw new Error('Illegal hls.js config: "liveMaxLatencyDurationCount" must be greater than "liveSyncDurationCount"');if(void 0!==t.liveMaxLatencyDuration&&(void 0===t.liveSyncDuration||t.liveMaxLatencyDuration<=t.liveSyncDuration))throw new Error('Illegal hls.js config: "liveMaxLatencyDuration" must be greater than "liveSyncDuration"');return Mt({},e,t)}(e.DefaultConfig,t);this.userConfig=t,(0,l.enableLogs)(r.debug,"Hls instance"),this._autoLevelCapping=-1,r.progressive&&function(e){var t=e.loader;t!==Pt&&t!==bt?(l.logger.log("[config]: Custom loader detected, cannot enable progressive streaming"),e.progressive=!1):function(){if(self.fetch&&self.AbortController&&self.ReadableStream&&self.Request)try{return new self.ReadableStream({}),!0}catch(e){}return!1}()&&(e.loader=Pt,e.progressive=!0,e.enableSoftwareAES=!0,l.logger.log("[config]: Progressive streaming enabled, using FetchLoader"))}(r);var i=r.abrController,n=r.bufferController,a=r.capLevelController,s=r.fpsController,o=this.abrController=new i(this),u=this.bufferController=new n(this),d=this.capLevelController=new a(this),h=new s(this),f=new O(this),c=new F(this),v=new H(this),g=this.levelController=new oe(this),p=new le(this),m=this.streamController=new st(this,p);d.setStreamController(m),h.setStreamController(m);var y=[f,c,g,m];this.networkControllers=y;var E=[o,u,d,h,v,p];this.audioTrackController=this.createController(r.audioTrackController,null,y),this.createController(r.audioStreamController,p,y),this.subtitleTrackController=this.createController(r.subtitleTrackController,null,y),this.createController(r.subtitleStreamController,p,y),this.createController(r.timelineController,null,E),this.emeController=this.createController(r.emeController,null,E),this.cmcdController=this.createController(r.cmcdController,null,E),this.latencyController=this.createController(W,null,E),this.coreComponents=E}e.isSupported=function(){return function(){var e=We();if(!e)return!1;var t=Ye(),r=e&&"function"==typeof e.isTypeSupported&&e.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"'),i=!t||t.prototype&&"function"==typeof t.prototype.appendBuffer&&"function"==typeof t.prototype.remove;return!!r&&!!i}()};var t,r,i,a=e.prototype;return a.createController=function(e,t,r){if(e){var i=t?new e(this,t):new e(this);return r&&r.push(i),i}return null},a.on=function(e,t,r){void 0===r&&(r=this),this._emitter.on(e,t,r)},a.once=function(e,t,r){void 0===r&&(r=this),this._emitter.once(e,t,r)},a.removeAllListeners=function(e){this._emitter.removeAllListeners(e)},a.off=function(e,t,r,i){void 0===r&&(r=this),this._emitter.off(e,t,r,i)},a.listeners=function(e){return this._emitter.listeners(e)},a.emit=function(e,t,r){return this._emitter.emit(e,t,r)},a.trigger=function(e,t){if(this.config.debug)return this.emit(e,e,t);try{return this.emit(e,e,t)}catch(t){l.logger.error("An internal error happened while handling event "+e+'. Error message: "'+t.message+'". Here is a stacktrace:',t),this.trigger(s.Events.ERROR,{type:o.ErrorTypes.OTHER_ERROR,details:o.ErrorDetails.INTERNAL_EXCEPTION,fatal:!1,event:e,error:t})}return!1},a.listenerCount=function(e){return this._emitter.listenerCount(e)},a.destroy=function(){l.logger.log("destroy"),this.trigger(s.Events.DESTROYING,void 0),this.detachMedia(),this.removeAllListeners(),this._autoLevelCapping=-1,this.url=null,this.networkControllers.forEach((function(e){return e.destroy()})),this.networkControllers.length=0,this.coreComponents.forEach((function(e){return e.destroy()})),this.coreComponents.length=0},a.attachMedia=function(e){l.logger.log("attachMedia"),this._media=e,this.trigger(s.Events.MEDIA_ATTACHING,{media:e})},a.detachMedia=function(){l.logger.log("detachMedia"),this.trigger(s.Events.MEDIA_DETACHING,void 0),this._media=null},a.loadSource=function(e){this.stopLoad();var t=this.media,r=this.url,i=this.url=n.buildAbsoluteURL(self.location.href,e,{alwaysNormalize:!0});l.logger.log("loadSource:"+i),t&&r&&r!==i&&this.bufferController.hasSourceTypes()&&(this.detachMedia(),this.attachMedia(t)),this.trigger(s.Events.MANIFEST_LOADING,{url:e})},a.startLoad=function(e){void 0===e&&(e=-1),l.logger.log("startLoad("+e+")"),this.networkControllers.forEach((function(t){t.startLoad(e)}))},a.stopLoad=function(){l.logger.log("stopLoad"),this.networkControllers.forEach((function(e){e.stopLoad()}))},a.swapAudioCodec=function(){l.logger.log("swapAudioCodec"),this.streamController.swapAudioCodec()},a.recoverMediaError=function(){l.logger.log("recoverMediaError");var e=this._media;this.detachMedia(),e&&this.attachMedia(e)},a.removeLevel=function(e,t){void 0===t&&(t=0),this.levelController.removeLevel(e,t)},t=e,i=[{key:"version",get:function(){return"1.2.9"}},{key:"Events",get:function(){return s.Events}},{key:"ErrorTypes",get:function(){return o.ErrorTypes}},{key:"ErrorDetails",get:function(){return o.ErrorDetails}},{key:"DefaultConfig",get:function(){return e.defaultConfig?e.defaultConfig:Gt},set:function(t){e.defaultConfig=t}}],(r=[{key:"levels",get:function(){return this.levelController.levels||[]}},{key:"currentLevel",get:function(){return this.streamController.currentLevel},set:function(e){l.logger.log("set currentLevel:"+e),this.loadLevel=e,this.abrController.clearTimer(),this.streamController.immediateLevelSwitch()}},{key:"nextLevel",get:function(){return this.streamController.nextLevel},set:function(e){l.logger.log("set nextLevel:"+e),this.levelController.manualLevel=e,this.streamController.nextLevelSwitch()}},{key:"loadLevel",get:function(){return this.levelController.level},set:function(e){l.logger.log("set loadLevel:"+e),this.levelController.manualLevel=e}},{key:"nextLoadLevel",get:function(){return this.levelController.nextLoadLevel},set:function(e){this.levelController.nextLoadLevel=e}},{key:"firstLevel",get:function(){return Math.max(this.levelController.firstLevel,this.minAutoLevel)},set:function(e){l.logger.log("set firstLevel:"+e),this.levelController.firstLevel=e}},{key:"startLevel",get:function(){return this.levelController.startLevel},set:function(e){l.logger.log("set startLevel:"+e),-1!==e&&(e=Math.max(e,this.minAutoLevel)),this.levelController.startLevel=e}},{key:"capLevelToPlayerSize",get:function(){return this.config.capLevelToPlayerSize},set:function(e){var t=!!e;t!==this.config.capLevelToPlayerSize&&(t?this.capLevelController.startCapping():(this.capLevelController.stopCapping(),this.autoLevelCapping=-1,this.streamController.nextLevelSwitch()),this.config.capLevelToPlayerSize=t)}},{key:"autoLevelCapping",get:function(){return this._autoLevelCapping},set:function(e){this._autoLevelCapping!==e&&(l.logger.log("set autoLevelCapping:"+e),this._autoLevelCapping=e)}},{key:"bandwidthEstimate",get:function(){var e=this.abrController.bwEstimator;return e?e.getEstimate():NaN}},{key:"autoLevelEnabled",get:function(){return-1===this.levelController.manualLevel}},{key:"manualLevel",get:function(){return this.levelController.manualLevel}},{key:"minAutoLevel",get:function(){var e=this.levels,t=this.config.minAutoBitrate;if(!e)return 0;for(var r=e.length,i=0;i<r;i++)if(e[i].maxBitrate>=t)return i;return 0}},{key:"maxAutoLevel",get:function(){var e=this.levels,t=this.autoLevelCapping;return-1===t&&e&&e.length?e.length-1:t}},{key:"nextAutoLevel",get:function(){return Math.min(Math.max(this.abrController.nextAutoLevel,this.minAutoLevel),this.maxAutoLevel)},set:function(e){this.abrController.nextAutoLevel=Math.max(this.minAutoLevel,e)}},{key:"playingDate",get:function(){return this.streamController.currentProgramDateTime}},{key:"mainForwardBufferInfo",get:function(){return this.streamController.getMainFwdBufferInfo()}},{key:"audioTracks",get:function(){var e=this.audioTrackController;return e?e.audioTracks:[]}},{key:"audioTrack",get:function(){var e=this.audioTrackController;return e?e.audioTrack:-1},set:function(e){var t=this.audioTrackController;t&&(t.audioTrack=e)}},{key:"subtitleTracks",get:function(){var e=this.subtitleTrackController;return e?e.subtitleTracks:[]}},{key:"subtitleTrack",get:function(){var e=this.subtitleTrackController;return e?e.subtitleTrack:-1},set:function(e){var t=this.subtitleTrackController;t&&(t.subtitleTrack=e)}},{key:"media",get:function(){return this._media}},{key:"subtitleDisplay",get:function(){var e=this.subtitleTrackController;return!!e&&e.subtitleDisplay},set:function(e){var t=this.subtitleTrackController;t&&(t.subtitleDisplay=e)}},{key:"lowLatencyMode",get:function(){return this.config.lowLatencyMode},set:function(e){this.config.lowLatencyMode=e}},{key:"liveSyncPosition",get:function(){return this.latencyController.liveSyncPosition}},{key:"latency",get:function(){return this.latencyController.latency}},{key:"maxLatency",get:function(){return this.latencyController.maxLatency}},{key:"targetLatency",get:function(){return this.latencyController.targetLatency}},{key:"drift",get:function(){return this.latencyController.drift}},{key:"forceStartLoad",get:function(){return this.streamController.forceStartLoad}}])&&Vt(t.prototype,r),i&&Vt(t,i),Object.defineProperty(t,"prototype",{writable:!1}),e}();Ht.defaultConfig=void 0},923:(e,t,r)=>{"use strict";r.r(t),r.d(t,{BaseSegment:()=>c,ElementaryStreamTypes:()=>i,Fragment:()=>v,Part:()=>g});var i,n=r(965),a=r(945),s=r(93),o=r(960),l=r(408);function u(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,d(e,t)}function d(e,t){return d=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},d(e,t)}function h(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}function f(e,t,r){return t&&h(e.prototype,t),r&&h(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}!function(e){e.AUDIO="audio",e.VIDEO="video",e.AUDIOVIDEO="audiovideo"}(i||(i={}));var c=function(){function e(e){var t;this._byteRange=null,this._url=null,this.baseurl=void 0,this.relurl=void 0,this.elementaryStreams=((t={})[i.AUDIO]=null,t[i.VIDEO]=null,t[i.AUDIOVIDEO]=null,t),this.baseurl=e}return e.prototype.setByteRange=function(e,t){var r=e.split("@",2),i=[];1===r.length?i[0]=t?t.byteRangeEndOffset:0:i[0]=parseInt(r[1]),i[1]=parseInt(r[0])+i[0],this._byteRange=i},f(e,[{key:"byteRange",get:function(){return this._byteRange?this._byteRange:[]}},{key:"byteRangeStartOffset",get:function(){return this.byteRange[0]}},{key:"byteRangeEndOffset",get:function(){return this.byteRange[1]}},{key:"url",get:function(){return!this._url&&this.baseurl&&this.relurl&&(this._url=(0,a.buildAbsoluteURL)(this.baseurl,this.relurl,{alwaysNormalize:!0})),this._url||""},set:function(e){this._url=e}}]),e}(),v=function(e){function t(t,r){var i;return(i=e.call(this,r)||this)._decryptdata=null,i.rawProgramDateTime=null,i.programDateTime=null,i.tagList=[],i.duration=0,i.sn=0,i.levelkey=void 0,i.type=void 0,i.loader=null,i.level=-1,i.cc=0,i.startPTS=void 0,i.endPTS=void 0,i.appendedPTS=void 0,i.startDTS=void 0,i.endDTS=void 0,i.start=0,i.deltaPTS=void 0,i.maxStartPTS=void 0,i.minEndPTS=void 0,i.stats=new l.LoadStats,i.urlId=0,i.data=void 0,i.bitrateTest=!1,i.title=null,i.initSegment=null,i.type=t,i}u(t,e);var r=t.prototype;return r.createInitializationVector=function(e){for(var t=new Uint8Array(16),r=12;r<16;r++)t[r]=e>>8*(15-r)&255;return t},r.setDecryptDataFromLevelKey=function(e,t){var r=e;return"AES-128"===(null==e?void 0:e.method)&&e.uri&&!e.iv&&((r=o.LevelKey.fromURI(e.uri)).method=e.method,r.iv=this.createInitializationVector(t),r.keyFormat="identity"),r},r.setElementaryStreamInfo=function(e,t,r,i,n,a){void 0===a&&(a=!1);var s=this.elementaryStreams,o=s[e];o?(o.startPTS=Math.min(o.startPTS,t),o.endPTS=Math.max(o.endPTS,r),o.startDTS=Math.min(o.startDTS,i),o.endDTS=Math.max(o.endDTS,n)):s[e]={startPTS:t,endPTS:r,startDTS:i,endDTS:n,partial:a}},r.clearElementaryStreamInfo=function(){var e=this.elementaryStreams;e[i.AUDIO]=null,e[i.VIDEO]=null,e[i.AUDIOVIDEO]=null},f(t,[{key:"decryptdata",get:function(){if(!this.levelkey&&!this._decryptdata)return null;if(!this._decryptdata&&this.levelkey){var e=this.sn;"number"!=typeof e&&(this.levelkey&&"AES-128"===this.levelkey.method&&!this.levelkey.iv&&s.logger.warn('missing IV for initialization segment with method="'+this.levelkey.method+'" - compliance issue'),e=0),this._decryptdata=this.setDecryptDataFromLevelKey(this.levelkey,e)}return this._decryptdata}},{key:"end",get:function(){return this.start+this.duration}},{key:"endProgramDateTime",get:function(){if(null===this.programDateTime)return null;if(!(0,n.isFiniteNumber)(this.programDateTime))return null;var e=(0,n.isFiniteNumber)(this.duration)?this.duration:0;return this.programDateTime+1e3*e}},{key:"encrypted",get:function(){var e;return!(null===(e=this.decryptdata)||void 0===e||!e.keyFormat||!this.decryptdata.uri)}}]),t}(c),g=function(e){function t(t,r,i,n,a){var s;(s=e.call(this,i)||this).fragOffset=0,s.duration=0,s.gap=!1,s.independent=!1,s.relurl=void 0,s.fragment=void 0,s.index=void 0,s.stats=new l.LoadStats,s.duration=t.decimalFloatingPoint("DURATION"),s.gap=t.bool("GAP"),s.independent=t.bool("INDEPENDENT"),s.relurl=t.enumeratedString("URI"),s.fragment=r,s.index=n;var o=t.enumeratedString("BYTERANGE");return o&&s.setByteRange(o,a),a&&(s.fragOffset=a.fragOffset+a.duration),s}return u(t,e),f(t,[{key:"start",get:function(){return this.fragment.start+this.fragOffset}},{key:"end",get:function(){return this.start+this.duration}},{key:"loaded",get:function(){var e=this.elementaryStreams;return!!(e.audio||e.video||e.audiovideo)}}]),t}(c)},960:(e,t,r)=>{"use strict";r.r(t),r.d(t,{LevelKey:()=>a});var i=r(945);function n(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}var a=function(){function e(e,t){this._uri=null,this.method=null,this.keyFormat=null,this.keyFormatVersions=null,this.keyID=null,this.key=null,this.iv=null,this._uri=t?(0,i.buildAbsoluteURL)(e,t,{alwaysNormalize:!0}):e}var t,r;return e.fromURL=function(t,r){return new e(t,r)},e.fromURI=function(t){return new e(t)},t=e,(r=[{key:"uri",get:function(){return this._uri}}])&&n(t.prototype,r),Object.defineProperty(t,"prototype",{writable:!1}),e}()},408:(e,t,r)=>{"use strict";r.r(t),r.d(t,{LoadStats:()=>i});var i=function(){this.aborted=!1,this.loaded=0,this.retry=0,this.total=0,this.chunkCount=0,this.bwEstimate=0,this.loading={start:0,first:0,end:0},this.parsing={start:0,end:0},this.buffering={start:0,first:0,end:0}}},965:(e,t,r)=>{"use strict";r.r(t),r.d(t,{MAX_SAFE_INTEGER:()=>n,isFiniteNumber:()=>i});var i=Number.isFinite||function(e){return"number"==typeof e&&isFinite(e)},n=Number.MAX_SAFE_INTEGER||9007199254740991},856:(e,t,r)=>{"use strict";var i;r.r(t),r.d(t,{MetadataSchema:()=>i}),function(e){e.audioId3="org.id3",e.dateRange="com.apple.quicktime.HLS",e.emsg="https://aomedia.org/emsg/ID3"}(i||(i={}))},308:(e,t,r)=>{"use strict";var i,n;r.r(t),r.d(t,{PlaylistContextType:()=>i,PlaylistLevelType:()=>n}),function(e){e.MANIFEST="manifest",e.LEVEL="level",e.AUDIO_TRACK="audioTrack",e.SUBTITLE_TRACK="subtitleTrack"}(i||(i={})),function(e){e.MAIN="main",e.AUDIO="audio",e.SUBTITLE="subtitle"}(n||(n={}))},93:(e,t,r)=>{"use strict";r.r(t),r.d(t,{enableLogs:()=>o,logger:()=>l});var i=function(){},n={trace:i,debug:i,log:i,warn:i,info:i,error:i},a=n;function s(e){var t=self.console[e];return t?t.bind(self.console,"["+e+"] >"):i}function o(e,t){if(self.console&&!0===e||"object"==typeof e){!function(e){for(var t=arguments.length,r=new Array(t>1?t-1:0),i=1;i<t;i++)r[i-1]=arguments[i];r.forEach((function(t){a[t]=e[t]?e[t].bind(e):s(t)}))}(e,"debug","log","info","warn","error");try{a.log('Debug logs enabled for "'+t+'"')}catch(e){a=n}}else a=n}var l=n},63:(e,t,r)=>{"use strict";r.r(t),r.d(t,{RemuxerTrackIdConfig:()=>l,appendUint8Array:()=>b,bin2str:()=>u,computeRawDurationFromSamples:()=>E,discardEPB:()=>R,findBox:()=>v,getDuration:()=>y,getStartDTS:()=>m,offsetStartDTS:()=>T,parseEmsg:()=>k,parseInitSegment:()=>p,parseSEIMessageFromNALu:()=>D,parseSamples:()=>L,parseSegmentIndex:()=>g,readSint32:()=>f,readUint16:()=>d,readUint32:()=>h,segmentValidRange:()=>S,writeUint32:()=>c});var i=r(145),n=r(923),a=r(181),s=Math.pow(2,32)-1,o=[].push,l={video:1,audio:2,id3:3,text:4};function u(e){return String.fromCharCode.apply(null,e)}function d(e,t){var r=e[t]<<8|e[t+1];return r<0?65536+r:r}function h(e,t){var r=f(e,t);return r<0?4294967296+r:r}function f(e,t){return e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3]}function c(e,t,r){e[t]=r>>24,e[t+1]=r>>16&255,e[t+2]=r>>8&255,e[t+3]=255&r}function v(e,t){var r=[];if(!t.length)return r;for(var i=e.byteLength,n=0;n<i;){var a=h(e,n),s=a>1?n+a:i;if(u(e.subarray(n+4,n+8))===t[0])if(1===t.length)r.push(e.subarray(n+8,s));else{var l=v(e.subarray(n+8,s),t.slice(1));l.length&&o.apply(r,l)}n=s}return r}function g(e){var t=[],r=e[0],i=8,n=h(e,i);i+=4,i+=0===r?8:16,i+=2;var a=e.length+0,s=d(e,i);i+=2;for(var o=0;o<s;o++){var l=i,u=h(e,l);l+=4;var f=2147483647&u;if(1==(2147483648&u)>>>31)return console.warn("SIDX has hierarchical references (not supported)"),null;var c=h(e,l);l+=4,t.push({referenceSize:f,subsegmentDuration:c,info:{duration:c/n,start:a,end:a+f-1}}),a+=f,i=l+=4}return{earliestPresentationTime:0,timescale:n,version:r,referencesCount:s,references:t}}function p(e){for(var t=[],r=v(e,["moov","trak"]),i=0;i<r.length;i++){var a=r[i],s=v(a,["tkhd"])[0];if(s){var o=s[0],l=0===o?12:20,d=h(s,l),f=v(a,["mdia","mdhd"])[0];if(f){var c=h(f,l=0===(o=f[0])?12:20),g=v(a,["mdia","hdlr"])[0];if(g){var p=u(g.subarray(8,12)),m={soun:n.ElementaryStreamTypes.AUDIO,vide:n.ElementaryStreamTypes.VIDEO}[p];if(m){var y=v(a,["mdia","minf","stbl","stsd"])[0],E=void 0;y&&(E=u(y.subarray(12,16))),t[d]={timescale:c,type:m},t[m]={timescale:c,id:d,codec:E}}}}}}return v(e,["moov","mvex","trex"]).forEach((function(e){var r=h(e,4),i=t[r];i&&(i.default={duration:h(e,12),flags:h(e,20)})})),t}function m(e,t){return v(t,["moof","traf"]).reduce((function(t,r){var i=v(r,["tfdt"])[0],n=i[0],a=v(r,["tfhd"]).reduce((function(t,r){var a=h(r,4),s=e[a];if(s){var o=h(i,4);1===n&&(o*=Math.pow(2,32),o+=h(i,8));var l=o/(s.timescale||9e4);if(isFinite(l)&&(null===t||l<t))return l}return t}),null);return null!==a&&isFinite(a)&&(null===t||a<t)?a:t}),null)||0}function y(e,t){for(var r=0,i=0,a=0,s=v(e,["moof","traf"]),o=0;o<s.length;o++){var l=s[o],u=v(l,["tfhd"])[0],d=t[h(u,4)];if(d){var f=d.default,c=h(u,0)|(null==f?void 0:f.flags),p=null==f?void 0:f.duration;8&c&&(p=h(u,2&c?12:8));for(var m=d.timescale||9e4,y=v(l,["trun"]),T=0;T<y.length;T++)!(r=E(y[T]))&&p&&(r=p*h(y[T],4)),d.type===n.ElementaryStreamTypes.VIDEO?i+=r/m:d.type===n.ElementaryStreamTypes.AUDIO&&(a+=r/m)}}if(0===i&&0===a){for(var S=0,b=v(e,["sidx"]),L=0;L<b.length;L++){var A=g(b[L]);null!=A&&A.references&&(S+=A.references.reduce((function(e,t){return e+t.info.duration||0}),0))}return S}return i||a}function E(e){var t=h(e,0),r=8;1&t&&(r+=4),4&t&&(r+=4);for(var i=0,n=h(e,4),a=0;a<n;a++)256&t&&(i+=h(e,r),r+=4),512&t&&(r+=4),1024&t&&(r+=4),2048&t&&(r+=4);return i}function T(e,t,r){v(t,["moof","traf"]).forEach((function(t){v(t,["tfhd"]).forEach((function(i){var n=h(i,4),a=e[n];if(a){var o=a.timescale||9e4;v(t,["tfdt"]).forEach((function(e){var t=e[0],i=h(e,4);if(0===t)i-=r*o,c(e,4,i=Math.max(i,0));else{i*=Math.pow(2,32),i+=h(e,8),i-=r*o,i=Math.max(i,0);var n=Math.floor(i/(s+1)),a=Math.floor(i%(s+1));c(e,4,n),c(e,8,a)}}))}}))}))}function S(e){var t={valid:null,remainder:null},r=v(e,["moof"]);if(!r)return t;if(r.length<2)return t.remainder=e,t;var n=r[r.length-1];return t.valid=(0,i.sliceUint8)(e,0,n.byteOffset-8),t.remainder=(0,i.sliceUint8)(e,n.byteOffset-8),t}function b(e,t){var r=new Uint8Array(e.length+t.length);return r.set(e),r.set(t,e.length),r}function L(e,t){var r=[],i=t.samples,a=t.timescale,s=t.id,o=!1;return v(i,["moof"]).map((function(l){var u=l.byteOffset-8;v(l,["traf"]).map((function(l){var d=v(l,["tfdt"]).map((function(e){var t=e[0],r=h(e,4);return 1===t&&(r*=Math.pow(2,32),r+=h(e,8)),r/a}))[0];return void 0!==d&&(e=d),v(l,["tfhd"]).map((function(d){var c=h(d,4),g=16777215&h(d,0),p=0,m=0!=(16&g),y=0,E=0!=(32&g),T=8;c===s&&(0!=(1&g)&&(T+=8),0!=(2&g)&&(T+=4),0!=(8&g)&&(p=h(d,T),T+=4),m&&(y=h(d,T),T+=4),E&&(T+=4),"video"===t.type&&(o=function(e){if(!e)return!1;var t=e.indexOf("."),r=t<0?e:e.substring(0,t);return"hvc1"===r||"hev1"===r||"dvh1"===r||"dvhe"===r}(t.codec)),v(l,["trun"]).map((function(s){var l=s[0],d=16777215&h(s,0),c=0!=(1&d),v=0,g=0!=(4&d),m=0!=(256&d),E=0,T=0!=(512&d),S=0,b=0!=(1024&d),L=0!=(2048&d),R=0,k=h(s,4),_=8;c&&(v=h(s,_),_+=4),g&&(_+=4);for(var x=v+u,I=0;I<k;I++){if(m?(E=h(s,_),_+=4):E=p,T?(S=h(s,_),_+=4):S=y,b&&(_+=4),L&&(R=0===l?h(s,_):f(s,_),_+=4),t.type===n.ElementaryStreamTypes.VIDEO)for(var w=0;w<S;){var C=h(i,x);A(o,i[x+=4])&&D(i.subarray(x,x+C),o?2:1,e+R/a,r),x+=C,w+=C+4}e+=E/a}})))}))}))})),r}function A(e,t){if(e){var r=t>>1&63;return 39===r||40===r}return 6==(31&t)}function D(e,t,r,i){var n=R(e),s=0;s+=t;for(var o=0,l=0,u=!1,f=0;s<n.length;){o=0;do{if(s>=n.length)break;o+=f=n[s++]}while(255===f);l=0;do{if(s>=n.length)break;l+=f=n[s++]}while(255===f);var c=n.length-s;if(!u&&4===o&&s<n.length){if(u=!0,181===n[s++]){var v=d(n,s);if(s+=2,49===v){var g=h(n,s);if(s+=4,1195456820===g){var p=n[s++];if(3===p){var m=n[s++],y=64&m,E=y?2+3*(31&m):0,T=new Uint8Array(E);if(y){T[0]=m;for(var S=1;S<E;S++)T[S]=n[s++]}i.push({type:p,payloadType:o,pts:r,bytes:T})}}}}}else if(5===o&&l<c){if(u=!0,l>16){for(var b=[],L=0;L<16;L++){var A=n[s++].toString(16);b.push(1==A.length?"0"+A:A),3!==L&&5!==L&&7!==L&&9!==L||b.push("-")}for(var D=l-16,k=new Uint8Array(D),_=0;_<D;_++)k[_]=n[s++];i.push({payloadType:o,pts:r,uuid:b.join(""),userData:(0,a.utf8ArrayToStr)(k),userDataBytes:k})}}else if(l<c)s+=l;else if(l>c)break}}function R(e){for(var t=e.byteLength,r=[],i=1;i<t-2;)0===e[i]&&0===e[i+1]&&3===e[i+2]?(r.push(i+2),i+=2):i++;if(0===r.length)return e;var n=t-r.length,a=new Uint8Array(n),s=0;for(i=0;i<n;s++,i++)s===r[0]&&(s++,r.shift()),a[i]=e[s];return a}function k(e){var t=e[0],r="",i="",n=0,a=0,s=0,o=0,l=0,d=0;if(0===t){for(;"\0"!==u(e.subarray(d,d+1));)r+=u(e.subarray(d,d+1)),d+=1;for(r+=u(e.subarray(d,d+1)),d+=1;"\0"!==u(e.subarray(d,d+1));)i+=u(e.subarray(d,d+1)),d+=1;i+=u(e.subarray(d,d+1)),d+=1,n=h(e,12),a=h(e,16),o=h(e,20),l=h(e,24),d=28}else if(1===t){n=h(e,d+=4);var f=h(e,d+=4),c=h(e,d+=4);for(d+=4,s=Math.pow(2,32)*f+c,Number.isSafeInteger(s)||(s=Number.MAX_SAFE_INTEGER,console.warn("Presentation time exceeds safe integer limit and wrapped to max safe integer in parsing emsg box")),o=h(e,d),l=h(e,d+=4),d+=4;"\0"!==u(e.subarray(d,d+1));)r+=u(e.subarray(d,d+1)),d+=1;for(r+=u(e.subarray(d,d+1)),d+=1;"\0"!==u(e.subarray(d,d+1));)i+=u(e.subarray(d,d+1)),d+=1;i+=u(e.subarray(d,d+1)),d+=1}return{schemeIdUri:r,value:i,timeScale:n,presentationTime:s,presentationTimeDelta:a,eventDuration:o,id:l,payload:e.subarray(d,e.byteLength)}}},145:(e,t,r)=>{"use strict";function i(e,t,r){return Uint8Array.prototype.slice?e.slice(t,r):new Uint8Array(Array.prototype.slice.call(e,t,r))}r.r(t),r.d(t,{sliceUint8:()=>i})},729:e=>{"use strict";var t=Object.prototype.hasOwnProperty,r="~";function i(){}function n(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||e,s),l=r?r+t:t;return e._events[l]?e._events[l].fn?e._events[l]=[e._events[l],o]:e._events[l].push(o):(e._events[l]=o,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new i:delete e._events[t]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var e,i,n=[];if(0===this._eventsCount)return n;for(i in e=this._events)t.call(e,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},o.prototype.listeners=function(e){var t=r?r+e:e,i=this._events[t];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;return s},o.prototype.listenerCount=function(e){var t=r?r+e:e,i=this._events[t];return i?i.fn?1:i.length:0},o.prototype.emit=function(e,t,i,n,a,s){var o=r?r+e:e;if(!this._events[o])return!1;var l,u,d=this._events[o],h=arguments.length;if(d.fn){switch(d.once&&this.removeListener(e,d.fn,void 0,!0),h){case 1:return d.fn.call(d.context),!0;case 2:return d.fn.call(d.context,t),!0;case 3:return d.fn.call(d.context,t,i),!0;case 4:return d.fn.call(d.context,t,i,n),!0;case 5:return d.fn.call(d.context,t,i,n,a),!0;case 6:return d.fn.call(d.context,t,i,n,a,s),!0}for(u=1,l=new Array(h-1);u<h;u++)l[u-1]=arguments[u];d.fn.apply(d.context,l)}else{var f,c=d.length;for(u=0;u<c;u++)switch(d[u].once&&this.removeListener(e,d[u].fn,void 0,!0),h){case 1:d[u].fn.call(d[u].context);break;case 2:d[u].fn.call(d[u].context,t);break;case 3:d[u].fn.call(d[u].context,t,i);break;case 4:d[u].fn.call(d[u].context,t,i,n);break;default:if(!l)for(f=1,l=new Array(h-1);f<h;f++)l[f-1]=arguments[f];d[u].fn.apply(d[u].context,l)}}return!0},o.prototype.on=function(e,t,r){return a(this,e,t,r,!1)},o.prototype.once=function(e,t,r){return a(this,e,t,r,!0)},o.prototype.removeListener=function(e,t,i,n){var a=r?r+e:e;if(!this._events[a])return this;if(!t)return s(this,a),this;var o=this._events[a];if(o.fn)o.fn!==t||n&&!o.once||i&&o.context!==i||s(this,a);else{for(var l=0,u=[],d=o.length;l<d;l++)(o[l].fn!==t||n&&!o[l].once||i&&o[l].context!==i)&&u.push(o[l]);u.length?this._events[a]=1===u.length?u[0]:u:s(this,a)}return this},o.prototype.removeAllListeners=function(e){var t;return e?(t=r?r+e:e,this._events[t]&&s(this,t)):(this._events=new i,this._eventsCount=0),this},o.prototype.off=o.prototype.removeListener,o.prototype.addListener=o.prototype.on,o.prefixed=r,o.EventEmitter=o,e.exports=o},945:function(e){var t,r,i,n,a;t=/^(?=((?:[a-zA-Z0-9+\-.]+:)?))\1(?=((?:\/\/[^\/?#]*)?))\2(?=((?:(?:[^?#\/]*\/)*[^;?#\/]*)?))\3((?:;[^?#]*)?)(\?[^#]*)?(#[^]*)?$/,r=/^(?=([^\/?#]*))\1([^]*)$/,i=/(?:\/|^)\.(?=\/)/g,n=/(?:\/|^)\.\.\/(?!\.\.\/)[^\/]*(?=\/)/g,a={buildAbsoluteURL:function(e,t,i){if(i=i||{},e=e.trim(),!(t=t.trim())){if(!i.alwaysNormalize)return e;var n=a.parseURL(e);if(!n)throw new Error("Error trying to parse base URL.");return n.path=a.normalizePath(n.path),a.buildURLFromParts(n)}var s=a.parseURL(t);if(!s)throw new Error("Error trying to parse relative URL.");if(s.scheme)return i.alwaysNormalize?(s.path=a.normalizePath(s.path),a.buildURLFromParts(s)):t;var o=a.parseURL(e);if(!o)throw new Error("Error trying to parse base URL.");if(!o.netLoc&&o.path&&"/"!==o.path[0]){var l=r.exec(o.path);o.netLoc=l[1],o.path=l[2]}o.netLoc&&!o.path&&(o.path="/");var u={scheme:o.scheme,netLoc:s.netLoc,path:null,params:s.params,query:s.query,fragment:s.fragment};if(!s.netLoc&&(u.netLoc=o.netLoc,"/"!==s.path[0]))if(s.path){var d=o.path,h=d.substring(0,d.lastIndexOf("/")+1)+s.path;u.path=a.normalizePath(h)}else u.path=o.path,s.params||(u.params=o.params,s.query||(u.query=o.query));return null===u.path&&(u.path=i.alwaysNormalize?a.normalizePath(s.path):s.path),a.buildURLFromParts(u)},parseURL:function(e){var r=t.exec(e);return r?{scheme:r[1]||"",netLoc:r[2]||"",path:r[3]||"",params:r[4]||"",query:r[5]||"",fragment:r[6]||""}:null},normalizePath:function(e){for(e=e.split("").reverse().join("").replace(i,"");e.length!==(e=e.replace(n,"")).length;);return e.split("").reverse().join("")},buildURLFromParts:function(e){return e.scheme+e.netLoc+e.path+e.params+e.query+e.fragment}},e.exports=a}},t={};function r(i){var n=t[i];if(void 0!==n)return n.exports;var a=t[i]={exports:{}};return e[i].call(a.exports,a,a.exports,r),a.exports}r.m=e,r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var i in t)r.o(t,i)&&!r.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var i=r(392);return i.default})())); -// @license-end diff --git a/public/js/hls.min.js b/public/js/hls.min.js new file mode 100644 index 0000000..b9098c5 --- /dev/null +++ b/public/js/hls.min.js @@ -0,0 +1,5 @@ +// @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0 +// @source https://github.com/video-dev/hls.js +// @version v1.5.1 +!function t(e){var r,i;r=this,i=function(){"use strict";function r(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),r.push.apply(r,i)}return r}function i(t){for(var e=1;e<arguments.length;e++){var i=null!=arguments[e]?arguments[e]:{};e%2?r(Object(i),!0).forEach((function(e){var r,a,s;r=t,a=e,s=i[e],(a=n(a))in r?Object.defineProperty(r,a,{value:s,enumerable:!0,configurable:!0,writable:!0}):r[a]=s})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(i)):r(Object(i)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(i,e))}))}return t}function n(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var r=t[Symbol.toPrimitive];if(void 0!==r){var i=r.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:String(e)}function a(t,e){for(var r=0;r<e.length;r++){var i=e[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,n(i.key),i)}}function s(t,e,r){return e&&a(t.prototype,e),r&&a(t,r),Object.defineProperty(t,"prototype",{writable:!1}),t}function o(){return o=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},o.apply(this,arguments)}function l(t,e){t.prototype=Object.create(e.prototype),t.prototype.constructor=t,h(t,e)}function u(t){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},u(t)}function h(t,e){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},h(t,e)}function d(t,e,r){return d=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}()?Reflect.construct.bind():function(t,e,r){var i=[null];i.push.apply(i,e);var n=new(Function.bind.apply(t,i));return r&&h(n,r.prototype),n},d.apply(null,arguments)}function c(t){var e="function"==typeof Map?new Map:void 0;return c=function(t){if(null===t||!function(t){try{return-1!==Function.toString.call(t).indexOf("[native code]")}catch(e){return"function"==typeof t}}(t))return t;if("function"!=typeof t)throw new TypeError("Super expression must either be null or a function");if(void 0!==e){if(e.has(t))return e.get(t);e.set(t,r)}function r(){return d(t,arguments,u(this).constructor)}return r.prototype=Object.create(t.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),h(r,t)},c(t)}function f(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,i=new Array(e);r<e;r++)i[r]=t[r];return i}function g(t,e){var r="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(r)return(r=r.call(t)).next.bind(r);if(Array.isArray(t)||(r=function(t,e){if(t){if("string"==typeof t)return f(t,e);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?f(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){r&&(t=r);var i=0;return function(){return i>=t.length?{done:!0}:{done:!1,value:t[i++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function v(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var m={exports:{}};!function(t,e){var r,i,n,a,s;r=/^(?=((?:[a-zA-Z0-9+\-.]+:)?))\1(?=((?:\/\/[^\/?#]*)?))\2(?=((?:(?:[^?#\/]*\/)*[^;?#\/]*)?))\3((?:;[^?#]*)?)(\?[^#]*)?(#[^]*)?$/,i=/^(?=([^\/?#]*))\1([^]*)$/,n=/(?:\/|^)\.(?=\/)/g,a=/(?:\/|^)\.\.\/(?!\.\.\/)[^\/]*(?=\/)/g,s={buildAbsoluteURL:function(t,e,r){if(r=r||{},t=t.trim(),!(e=e.trim())){if(!r.alwaysNormalize)return t;var n=s.parseURL(t);if(!n)throw new Error("Error trying to parse base URL.");return n.path=s.normalizePath(n.path),s.buildURLFromParts(n)}var a=s.parseURL(e);if(!a)throw new Error("Error trying to parse relative URL.");if(a.scheme)return r.alwaysNormalize?(a.path=s.normalizePath(a.path),s.buildURLFromParts(a)):e;var o=s.parseURL(t);if(!o)throw new Error("Error trying to parse base URL.");if(!o.netLoc&&o.path&&"/"!==o.path[0]){var l=i.exec(o.path);o.netLoc=l[1],o.path=l[2]}o.netLoc&&!o.path&&(o.path="/");var u={scheme:o.scheme,netLoc:a.netLoc,path:null,params:a.params,query:a.query,fragment:a.fragment};if(!a.netLoc&&(u.netLoc=o.netLoc,"/"!==a.path[0]))if(a.path){var h=o.path,d=h.substring(0,h.lastIndexOf("/")+1)+a.path;u.path=s.normalizePath(d)}else u.path=o.path,a.params||(u.params=o.params,a.query||(u.query=o.query));return null===u.path&&(u.path=r.alwaysNormalize?s.normalizePath(a.path):a.path),s.buildURLFromParts(u)},parseURL:function(t){var e=r.exec(t);return e?{scheme:e[1]||"",netLoc:e[2]||"",path:e[3]||"",params:e[4]||"",query:e[5]||"",fragment:e[6]||""}:null},normalizePath:function(t){for(t=t.split("").reverse().join("").replace(n,"");t.length!==(t=t.replace(a,"")).length;);return t.split("").reverse().join("")},buildURLFromParts:function(t){return t.scheme+t.netLoc+t.path+t.params+t.query+t.fragment}},t.exports=s}(m);var p=m.exports,y=Number.isFinite||function(t){return"number"==typeof t&&isFinite(t)},E=Number.isSafeInteger||function(t){return"number"==typeof t&&Math.abs(t)<=T},T=Number.MAX_SAFE_INTEGER||9007199254740991,S=function(t){return t.MEDIA_ATTACHING="hlsMediaAttaching",t.MEDIA_ATTACHED="hlsMediaAttached",t.MEDIA_DETACHING="hlsMediaDetaching",t.MEDIA_DETACHED="hlsMediaDetached",t.BUFFER_RESET="hlsBufferReset",t.BUFFER_CODECS="hlsBufferCodecs",t.BUFFER_CREATED="hlsBufferCreated",t.BUFFER_APPENDING="hlsBufferAppending",t.BUFFER_APPENDED="hlsBufferAppended",t.BUFFER_EOS="hlsBufferEos",t.BUFFER_FLUSHING="hlsBufferFlushing",t.BUFFER_FLUSHED="hlsBufferFlushed",t.MANIFEST_LOADING="hlsManifestLoading",t.MANIFEST_LOADED="hlsManifestLoaded",t.MANIFEST_PARSED="hlsManifestParsed",t.LEVEL_SWITCHING="hlsLevelSwitching",t.LEVEL_SWITCHED="hlsLevelSwitched",t.LEVEL_LOADING="hlsLevelLoading",t.LEVEL_LOADED="hlsLevelLoaded",t.LEVEL_UPDATED="hlsLevelUpdated",t.LEVEL_PTS_UPDATED="hlsLevelPtsUpdated",t.LEVELS_UPDATED="hlsLevelsUpdated",t.AUDIO_TRACKS_UPDATED="hlsAudioTracksUpdated",t.AUDIO_TRACK_SWITCHING="hlsAudioTrackSwitching",t.AUDIO_TRACK_SWITCHED="hlsAudioTrackSwitched",t.AUDIO_TRACK_LOADING="hlsAudioTrackLoading",t.AUDIO_TRACK_LOADED="hlsAudioTrackLoaded",t.SUBTITLE_TRACKS_UPDATED="hlsSubtitleTracksUpdated",t.SUBTITLE_TRACKS_CLEARED="hlsSubtitleTracksCleared",t.SUBTITLE_TRACK_SWITCH="hlsSubtitleTrackSwitch",t.SUBTITLE_TRACK_LOADING="hlsSubtitleTrackLoading",t.SUBTITLE_TRACK_LOADED="hlsSubtitleTrackLoaded",t.SUBTITLE_FRAG_PROCESSED="hlsSubtitleFragProcessed",t.CUES_PARSED="hlsCuesParsed",t.NON_NATIVE_TEXT_TRACKS_FOUND="hlsNonNativeTextTracksFound",t.INIT_PTS_FOUND="hlsInitPtsFound",t.FRAG_LOADING="hlsFragLoading",t.FRAG_LOAD_EMERGENCY_ABORTED="hlsFragLoadEmergencyAborted",t.FRAG_LOADED="hlsFragLoaded",t.FRAG_DECRYPTED="hlsFragDecrypted",t.FRAG_PARSING_INIT_SEGMENT="hlsFragParsingInitSegment",t.FRAG_PARSING_USERDATA="hlsFragParsingUserdata",t.FRAG_PARSING_METADATA="hlsFragParsingMetadata",t.FRAG_PARSED="hlsFragParsed",t.FRAG_BUFFERED="hlsFragBuffered",t.FRAG_CHANGED="hlsFragChanged",t.FPS_DROP="hlsFpsDrop",t.FPS_DROP_LEVEL_CAPPING="hlsFpsDropLevelCapping",t.MAX_AUTO_LEVEL_UPDATED="hlsMaxAutoLevelUpdated",t.ERROR="hlsError",t.DESTROYING="hlsDestroying",t.KEY_LOADING="hlsKeyLoading",t.KEY_LOADED="hlsKeyLoaded",t.LIVE_BACK_BUFFER_REACHED="hlsLiveBackBufferReached",t.BACK_BUFFER_REACHED="hlsBackBufferReached",t.STEERING_MANIFEST_LOADED="hlsSteeringManifestLoaded",t}({}),L=function(t){return t.NETWORK_ERROR="networkError",t.MEDIA_ERROR="mediaError",t.KEY_SYSTEM_ERROR="keySystemError",t.MUX_ERROR="muxError",t.OTHER_ERROR="otherError",t}({}),A=function(t){return t.KEY_SYSTEM_NO_KEYS="keySystemNoKeys",t.KEY_SYSTEM_NO_ACCESS="keySystemNoAccess",t.KEY_SYSTEM_NO_SESSION="keySystemNoSession",t.KEY_SYSTEM_NO_CONFIGURED_LICENSE="keySystemNoConfiguredLicense",t.KEY_SYSTEM_LICENSE_REQUEST_FAILED="keySystemLicenseRequestFailed",t.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED="keySystemServerCertificateRequestFailed",t.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED="keySystemServerCertificateUpdateFailed",t.KEY_SYSTEM_SESSION_UPDATE_FAILED="keySystemSessionUpdateFailed",t.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED="keySystemStatusOutputRestricted",t.KEY_SYSTEM_STATUS_INTERNAL_ERROR="keySystemStatusInternalError",t.MANIFEST_LOAD_ERROR="manifestLoadError",t.MANIFEST_LOAD_TIMEOUT="manifestLoadTimeOut",t.MANIFEST_PARSING_ERROR="manifestParsingError",t.MANIFEST_INCOMPATIBLE_CODECS_ERROR="manifestIncompatibleCodecsError",t.LEVEL_EMPTY_ERROR="levelEmptyError",t.LEVEL_LOAD_ERROR="levelLoadError",t.LEVEL_LOAD_TIMEOUT="levelLoadTimeOut",t.LEVEL_PARSING_ERROR="levelParsingError",t.LEVEL_SWITCH_ERROR="levelSwitchError",t.AUDIO_TRACK_LOAD_ERROR="audioTrackLoadError",t.AUDIO_TRACK_LOAD_TIMEOUT="audioTrackLoadTimeOut",t.SUBTITLE_LOAD_ERROR="subtitleTrackLoadError",t.SUBTITLE_TRACK_LOAD_TIMEOUT="subtitleTrackLoadTimeOut",t.FRAG_LOAD_ERROR="fragLoadError",t.FRAG_LOAD_TIMEOUT="fragLoadTimeOut",t.FRAG_DECRYPT_ERROR="fragDecryptError",t.FRAG_PARSING_ERROR="fragParsingError",t.FRAG_GAP="fragGap",t.REMUX_ALLOC_ERROR="remuxAllocError",t.KEY_LOAD_ERROR="keyLoadError",t.KEY_LOAD_TIMEOUT="keyLoadTimeOut",t.BUFFER_ADD_CODEC_ERROR="bufferAddCodecError",t.BUFFER_INCOMPATIBLE_CODECS_ERROR="bufferIncompatibleCodecsError",t.BUFFER_APPEND_ERROR="bufferAppendError",t.BUFFER_APPENDING_ERROR="bufferAppendingError",t.BUFFER_STALLED_ERROR="bufferStalledError",t.BUFFER_FULL_ERROR="bufferFullError",t.BUFFER_SEEK_OVER_HOLE="bufferSeekOverHole",t.BUFFER_NUDGE_ON_STALL="bufferNudgeOnStall",t.INTERNAL_EXCEPTION="internalException",t.INTERNAL_ABORTED="aborted",t.UNKNOWN="unknown",t}({}),R=function(){},k={trace:R,debug:R,log:R,warn:R,info:R,error:R},b=k;function D(t){for(var e=arguments.length,r=new Array(e>1?e-1:0),i=1;i<e;i++)r[i-1]=arguments[i];r.forEach((function(e){b[e]=t[e]?t[e].bind(t):function(t){var e=self.console[t];return e?e.bind(self.console,"["+t+"] >"):R}(e)}))}function I(t,e){if("object"==typeof console&&!0===t||"object"==typeof t){D(t,"debug","log","info","warn","error");try{b.log('Debug logs enabled for "'+e+'" in hls.js version 1.5.1')}catch(t){b=k}}else b=k}var w=b,C=/^(\d+)x(\d+)$/,_=/(.+?)=(".*?"|.*?)(?:,|$)/g,x=function(){function t(e){"string"==typeof e&&(e=t.parseAttrList(e)),o(this,e)}var e=t.prototype;return e.decimalInteger=function(t){var e=parseInt(this[t],10);return e>Number.MAX_SAFE_INTEGER?1/0:e},e.hexadecimalInteger=function(t){if(this[t]){var e=(this[t]||"0x").slice(2);e=(1&e.length?"0":"")+e;for(var r=new Uint8Array(e.length/2),i=0;i<e.length/2;i++)r[i]=parseInt(e.slice(2*i,2*i+2),16);return r}return null},e.hexadecimalIntegerAsNumber=function(t){var e=parseInt(this[t],16);return e>Number.MAX_SAFE_INTEGER?1/0:e},e.decimalFloatingPoint=function(t){return parseFloat(this[t])},e.optionalFloat=function(t,e){var r=this[t];return r?parseFloat(r):e},e.enumeratedString=function(t){return this[t]},e.bool=function(t){return"YES"===this[t]},e.decimalResolution=function(t){var e=C.exec(this[t]);if(null!==e)return{width:parseInt(e[1],10),height:parseInt(e[2],10)}},t.parseAttrList=function(t){var e,r={};for(_.lastIndex=0;null!==(e=_.exec(t));){var i=e[2];0===i.indexOf('"')&&i.lastIndexOf('"')===i.length-1&&(i=i.slice(1,-1)),r[e[1].trim()]=i}return r},s(t,[{key:"clientAttrs",get:function(){return Object.keys(this).filter((function(t){return"X-"===t.substring(0,2)}))}}]),t}();function P(t){return"SCTE35-OUT"===t||"SCTE35-IN"===t}var F=function(){function t(t,e){if(this.attr=void 0,this._startDate=void 0,this._endDate=void 0,this._badValueForSameId=void 0,e){var r=e.attr;for(var i in r)if(Object.prototype.hasOwnProperty.call(t,i)&&t[i]!==r[i]){w.warn('DATERANGE tag attribute: "'+i+'" does not match for tags with ID: "'+t.ID+'"'),this._badValueForSameId=i;break}t=o(new x({}),r,t)}if(this.attr=t,this._startDate=new Date(t["START-DATE"]),"END-DATE"in this.attr){var n=new Date(this.attr["END-DATE"]);y(n.getTime())&&(this._endDate=n)}}return s(t,[{key:"id",get:function(){return this.attr.ID}},{key:"class",get:function(){return this.attr.CLASS}},{key:"startDate",get:function(){return this._startDate}},{key:"endDate",get:function(){if(this._endDate)return this._endDate;var t=this.duration;return null!==t?new Date(this._startDate.getTime()+1e3*t):null}},{key:"duration",get:function(){if("DURATION"in this.attr){var t=this.attr.decimalFloatingPoint("DURATION");if(y(t))return t}else if(this._endDate)return(this._endDate.getTime()-this._startDate.getTime())/1e3;return null}},{key:"plannedDuration",get:function(){return"PLANNED-DURATION"in this.attr?this.attr.decimalFloatingPoint("PLANNED-DURATION"):null}},{key:"endOnNext",get:function(){return this.attr.bool("END-ON-NEXT")}},{key:"isValid",get:function(){return!!this.id&&!this._badValueForSameId&&y(this.startDate.getTime())&&(null===this.duration||this.duration>=0)&&(!this.endOnNext||!!this.class)}}]),t}(),M=function(){this.aborted=!1,this.loaded=0,this.retry=0,this.total=0,this.chunkCount=0,this.bwEstimate=0,this.loading={start:0,first:0,end:0},this.parsing={start:0,end:0},this.buffering={start:0,first:0,end:0}},O="audio",N="video",U="audiovideo",B=function(){function t(t){var e;this._byteRange=null,this._url=null,this.baseurl=void 0,this.relurl=void 0,this.elementaryStreams=((e={})[O]=null,e[N]=null,e[U]=null,e),this.baseurl=t}return t.prototype.setByteRange=function(t,e){var r,i=t.split("@",2);r=1===i.length?(null==e?void 0:e.byteRangeEndOffset)||0:parseInt(i[1]),this._byteRange=[r,parseInt(i[0])+r]},s(t,[{key:"byteRange",get:function(){return this._byteRange?this._byteRange:[]}},{key:"byteRangeStartOffset",get:function(){return this.byteRange[0]}},{key:"byteRangeEndOffset",get:function(){return this.byteRange[1]}},{key:"url",get:function(){return!this._url&&this.baseurl&&this.relurl&&(this._url=p.buildAbsoluteURL(this.baseurl,this.relurl,{alwaysNormalize:!0})),this._url||""},set:function(t){this._url=t}}]),t}(),G=function(t){function e(e,r){var i;return(i=t.call(this,r)||this)._decryptdata=null,i.rawProgramDateTime=null,i.programDateTime=null,i.tagList=[],i.duration=0,i.sn=0,i.levelkeys=void 0,i.type=void 0,i.loader=null,i.keyLoader=null,i.level=-1,i.cc=0,i.startPTS=void 0,i.endPTS=void 0,i.startDTS=void 0,i.endDTS=void 0,i.start=0,i.deltaPTS=void 0,i.maxStartPTS=void 0,i.minEndPTS=void 0,i.stats=new M,i.data=void 0,i.bitrateTest=!1,i.title=null,i.initSegment=null,i.endList=void 0,i.gap=void 0,i.urlId=0,i.type=e,i}l(e,t);var r=e.prototype;return r.setKeyFormat=function(t){if(this.levelkeys){var e=this.levelkeys[t];e&&!this._decryptdata&&(this._decryptdata=e.getDecryptData(this.sn))}},r.abortRequests=function(){var t,e;null==(t=this.loader)||t.abort(),null==(e=this.keyLoader)||e.abort()},r.setElementaryStreamInfo=function(t,e,r,i,n,a){void 0===a&&(a=!1);var s=this.elementaryStreams,o=s[t];o?(o.startPTS=Math.min(o.startPTS,e),o.endPTS=Math.max(o.endPTS,r),o.startDTS=Math.min(o.startDTS,i),o.endDTS=Math.max(o.endDTS,n)):s[t]={startPTS:e,endPTS:r,startDTS:i,endDTS:n,partial:a}},r.clearElementaryStreamInfo=function(){var t=this.elementaryStreams;t[O]=null,t[N]=null,t[U]=null},s(e,[{key:"decryptdata",get:function(){if(!this.levelkeys&&!this._decryptdata)return null;if(!this._decryptdata&&this.levelkeys&&!this.levelkeys.NONE){var t=this.levelkeys.identity;if(t)this._decryptdata=t.getDecryptData(this.sn);else{var e=Object.keys(this.levelkeys);if(1===e.length)return this._decryptdata=this.levelkeys[e[0]].getDecryptData(this.sn)}}return this._decryptdata}},{key:"end",get:function(){return this.start+this.duration}},{key:"endProgramDateTime",get:function(){if(null===this.programDateTime)return null;if(!y(this.programDateTime))return null;var t=y(this.duration)?this.duration:0;return this.programDateTime+1e3*t}},{key:"encrypted",get:function(){var t;if(null!=(t=this._decryptdata)&&t.encrypted)return!0;if(this.levelkeys){var e=Object.keys(this.levelkeys),r=e.length;if(r>1||1===r&&this.levelkeys[e[0]].encrypted)return!0}return!1}}]),e}(B),K=function(t){function e(e,r,i,n,a){var s;(s=t.call(this,i)||this).fragOffset=0,s.duration=0,s.gap=!1,s.independent=!1,s.relurl=void 0,s.fragment=void 0,s.index=void 0,s.stats=new M,s.duration=e.decimalFloatingPoint("DURATION"),s.gap=e.bool("GAP"),s.independent=e.bool("INDEPENDENT"),s.relurl=e.enumeratedString("URI"),s.fragment=r,s.index=n;var o=e.enumeratedString("BYTERANGE");return o&&s.setByteRange(o,a),a&&(s.fragOffset=a.fragOffset+a.duration),s}return l(e,t),s(e,[{key:"start",get:function(){return this.fragment.start+this.fragOffset}},{key:"end",get:function(){return this.start+this.duration}},{key:"loaded",get:function(){var t=this.elementaryStreams;return!!(t.audio||t.video||t.audiovideo)}}]),e}(B),H=function(){function t(t){this.PTSKnown=!1,this.alignedSliding=!1,this.averagetargetduration=void 0,this.endCC=0,this.endSN=0,this.fragments=void 0,this.fragmentHint=void 0,this.partList=null,this.dateRanges=void 0,this.live=!0,this.ageHeader=0,this.advancedDateTime=void 0,this.updated=!0,this.advanced=!0,this.availabilityDelay=void 0,this.misses=0,this.startCC=0,this.startSN=0,this.startTimeOffset=null,this.targetduration=0,this.totalduration=0,this.type=null,this.url=void 0,this.m3u8="",this.version=null,this.canBlockReload=!1,this.canSkipUntil=0,this.canSkipDateRanges=!1,this.skippedSegments=0,this.recentlyRemovedDateranges=void 0,this.partHoldBack=0,this.holdBack=0,this.partTarget=0,this.preloadHint=void 0,this.renditionReports=void 0,this.tuneInGoal=0,this.deltaUpdateFailed=void 0,this.driftStartTime=0,this.driftEndTime=0,this.driftStart=0,this.driftEnd=0,this.encryptedFragments=void 0,this.playlistParsingError=null,this.variableList=null,this.hasVariableRefs=!1,this.fragments=[],this.encryptedFragments=[],this.dateRanges={},this.url=t}return t.prototype.reloaded=function(t){if(!t)return this.advanced=!0,void(this.updated=!0);var e=this.lastPartSn-t.lastPartSn,r=this.lastPartIndex-t.lastPartIndex;this.updated=this.endSN!==t.endSN||!!r||!!e||!this.live,this.advanced=this.endSN>t.endSN||e>0||0===e&&r>0,this.updated||this.advanced?this.misses=Math.floor(.6*t.misses):this.misses=t.misses+1,this.availabilityDelay=t.availabilityDelay},s(t,[{key:"hasProgramDateTime",get:function(){return!!this.fragments.length&&y(this.fragments[this.fragments.length-1].programDateTime)}},{key:"levelTargetDuration",get:function(){return this.averagetargetduration||this.targetduration||10}},{key:"drift",get:function(){var t=this.driftEndTime-this.driftStartTime;return t>0?1e3*(this.driftEnd-this.driftStart)/t:1}},{key:"edge",get:function(){return this.partEnd||this.fragmentEnd}},{key:"partEnd",get:function(){var t;return null!=(t=this.partList)&&t.length?this.partList[this.partList.length-1].end:this.fragmentEnd}},{key:"fragmentEnd",get:function(){var t;return null!=(t=this.fragments)&&t.length?this.fragments[this.fragments.length-1].end:0}},{key:"age",get:function(){return this.advancedDateTime?Math.max(Date.now()-this.advancedDateTime,0)/1e3:0}},{key:"lastPartIndex",get:function(){var t;return null!=(t=this.partList)&&t.length?this.partList[this.partList.length-1].index:-1}},{key:"lastPartSn",get:function(){var t;return null!=(t=this.partList)&&t.length?this.partList[this.partList.length-1].fragment.sn:this.endSN}}]),t}();function V(t){return Uint8Array.from(atob(t),(function(t){return t.charCodeAt(0)}))}function Y(t){var e,r,i=t.split(":"),n=null;if("data"===i[0]&&2===i.length){var a=i[1].split(";"),s=a[a.length-1].split(",");if(2===s.length){var o="base64"===s[0],l=s[1];o?(a.splice(-1,1),n=V(l)):(e=W(l).subarray(0,16),(r=new Uint8Array(16)).set(e,16-e.length),n=r)}}return n}function W(t){return Uint8Array.from(unescape(encodeURIComponent(t)),(function(t){return t.charCodeAt(0)}))}var j="undefined"!=typeof self?self:void 0,q={CLEARKEY:"org.w3.clearkey",FAIRPLAY:"com.apple.fps",PLAYREADY:"com.microsoft.playready",WIDEVINE:"com.widevine.alpha"},X="org.w3.clearkey",z="com.apple.streamingkeydelivery",Q="com.microsoft.playready",J="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed";function $(t){switch(t){case z:return q.FAIRPLAY;case Q:return q.PLAYREADY;case J:return q.WIDEVINE;case X:return q.CLEARKEY}}var Z="edef8ba979d64acea3c827dcd51d21ed";function tt(t){switch(t){case q.FAIRPLAY:return z;case q.PLAYREADY:return Q;case q.WIDEVINE:return J;case q.CLEARKEY:return X}}function et(t){var e=t.drmSystems,r=t.widevineLicenseUrl,i=e?[q.FAIRPLAY,q.WIDEVINE,q.PLAYREADY,q.CLEARKEY].filter((function(t){return!!e[t]})):[];return!i[q.WIDEVINE]&&r&&i.push(q.WIDEVINE),i}var rt,it=null!=j&&null!=(rt=j.navigator)&&rt.requestMediaKeySystemAccess?self.navigator.requestMediaKeySystemAccess.bind(self.navigator):null;function nt(t,e,r){return Uint8Array.prototype.slice?t.slice(e,r):new Uint8Array(Array.prototype.slice.call(t,e,r))}var at,st=function(t,e){return e+10<=t.length&&73===t[e]&&68===t[e+1]&&51===t[e+2]&&t[e+3]<255&&t[e+4]<255&&t[e+6]<128&&t[e+7]<128&&t[e+8]<128&&t[e+9]<128},ot=function(t,e){return e+10<=t.length&&51===t[e]&&68===t[e+1]&&73===t[e+2]&&t[e+3]<255&&t[e+4]<255&&t[e+6]<128&&t[e+7]<128&&t[e+8]<128&&t[e+9]<128},lt=function(t,e){for(var r=e,i=0;st(t,e);)i+=10,i+=ut(t,e+6),ot(t,e+10)&&(i+=10),e+=i;if(i>0)return t.subarray(r,r+i)},ut=function(t,e){var r=0;return r=(127&t[e])<<21,r|=(127&t[e+1])<<14,r|=(127&t[e+2])<<7,r|=127&t[e+3]},ht=function(t,e){return st(t,e)&&ut(t,e+6)+10<=t.length-e},dt=function(t){for(var e=gt(t),r=0;r<e.length;r++){var i=e[r];if(ct(i))return Et(i)}},ct=function(t){return t&&"PRIV"===t.key&&"com.apple.streaming.transportStreamTimestamp"===t.info},ft=function(t){var e=String.fromCharCode(t[0],t[1],t[2],t[3]),r=ut(t,4);return{type:e,size:r,data:t.subarray(10,10+r)}},gt=function(t){for(var e=0,r=[];st(t,e);){for(var i=ut(t,e+6),n=(e+=10)+i;e+8<n;){var a=ft(t.subarray(e)),s=vt(a);s&&r.push(s),e+=a.size+10}ot(t,e)&&(e+=10)}return r},vt=function(t){return"PRIV"===t.type?mt(t):"W"===t.type[0]?yt(t):pt(t)},mt=function(t){if(!(t.size<2)){var e=Tt(t.data,!0),r=new Uint8Array(t.data.subarray(e.length+1));return{key:t.type,info:e,data:r.buffer}}},pt=function(t){if(!(t.size<2)){if("TXXX"===t.type){var e=1,r=Tt(t.data.subarray(e),!0);e+=r.length+1;var i=Tt(t.data.subarray(e));return{key:t.type,info:r,data:i}}var n=Tt(t.data.subarray(1));return{key:t.type,data:n}}},yt=function(t){if("WXXX"===t.type){if(t.size<2)return;var e=1,r=Tt(t.data.subarray(e),!0);e+=r.length+1;var i=Tt(t.data.subarray(e));return{key:t.type,info:r,data:i}}var n=Tt(t.data);return{key:t.type,data:n}},Et=function(t){if(8===t.data.byteLength){var e=new Uint8Array(t.data),r=1&e[3],i=(e[4]<<23)+(e[5]<<15)+(e[6]<<7)+e[7];return i/=45,r&&(i+=47721858.84),Math.round(i)}},Tt=function(t,e){void 0===e&&(e=!1);var r=St();if(r){var i=r.decode(t);if(e){var n=i.indexOf("\0");return-1!==n?i.substring(0,n):i}return i.replace(/\0/g,"")}for(var a,s,o,l=t.length,u="",h=0;h<l;){if(0===(a=t[h++])&&e)return u;if(0!==a&&3!==a)switch(a>>4){case 0:case 1:case 2:case 3:case 4:case 5:case 6:case 7:u+=String.fromCharCode(a);break;case 12:case 13:s=t[h++],u+=String.fromCharCode((31&a)<<6|63&s);break;case 14:s=t[h++],o=t[h++],u+=String.fromCharCode((15&a)<<12|(63&s)<<6|(63&o)<<0)}}return u};function St(){if(!navigator.userAgent.includes("PlayStation 4"))return at||void 0===self.TextDecoder||(at=new self.TextDecoder("utf-8")),at}var Lt=function(t){for(var e="",r=0;r<t.length;r++){var i=t[r].toString(16);i.length<2&&(i="0"+i),e+=i}return e},At=Math.pow(2,32)-1,Rt=[].push,kt={video:1,audio:2,id3:3,text:4};function bt(t){return String.fromCharCode.apply(null,t)}function Dt(t,e){var r=t[e]<<8|t[e+1];return r<0?65536+r:r}function It(t,e){var r=wt(t,e);return r<0?4294967296+r:r}function wt(t,e){return t[e]<<24|t[e+1]<<16|t[e+2]<<8|t[e+3]}function Ct(t,e,r){t[e]=r>>24,t[e+1]=r>>16&255,t[e+2]=r>>8&255,t[e+3]=255&r}function _t(t,e){var r=[];if(!e.length)return r;for(var i=t.byteLength,n=0;n<i;){var a=It(t,n),s=a>1?n+a:i;if(bt(t.subarray(n+4,n+8))===e[0])if(1===e.length)r.push(t.subarray(n+8,s));else{var o=_t(t.subarray(n+8,s),e.slice(1));o.length&&Rt.apply(r,o)}n=s}return r}function xt(t){var e=[],r=t[0],i=8,n=It(t,i);i+=4,i+=0===r?8:16,i+=2;var a=t.length+0,s=Dt(t,i);i+=2;for(var o=0;o<s;o++){var l=i,u=It(t,l);l+=4;var h=2147483647&u;if(1==(2147483648&u)>>>31)return w.warn("SIDX has hierarchical references (not supported)"),null;var d=It(t,l);l+=4,e.push({referenceSize:h,subsegmentDuration:d,info:{duration:d/n,start:a,end:a+h-1}}),a+=h,i=l+=4}return{earliestPresentationTime:0,timescale:n,version:r,referencesCount:s,references:e}}function Pt(t){for(var e=[],r=_t(t,["moov","trak"]),n=0;n<r.length;n++){var a=r[n],s=_t(a,["tkhd"])[0];if(s){var o=s[0],l=It(s,0===o?12:20),u=_t(a,["mdia","mdhd"])[0];if(u){var h=It(u,0===(o=u[0])?12:20),d=_t(a,["mdia","hdlr"])[0];if(d){var c=bt(d.subarray(8,12)),f={soun:O,vide:N}[c];if(f){var g=Ft(_t(a,["mdia","minf","stbl","stsd"])[0]);e[l]={timescale:h,type:f},e[f]=i({timescale:h,id:l},g)}}}}}return _t(t,["moov","mvex","trex"]).forEach((function(t){var r=It(t,4),i=e[r];i&&(i.default={duration:It(t,12),flags:It(t,20)})})),e}function Ft(t){var e=t.subarray(8),r=e.subarray(86),i=bt(e.subarray(4,8)),n=i,a="enca"===i||"encv"===i;if(a){var s=_t(e,[i])[0];_t(s.subarray("enca"===i?28:78),["sinf"]).forEach((function(t){var e=_t(t,["schm"])[0];if(e){var r=bt(e.subarray(4,8));if("cbcs"===r||"cenc"===r){var i=_t(t,["frma"])[0];i&&(n=bt(i))}}}))}switch(n){case"avc1":case"avc2":case"avc3":case"avc4":var o=_t(r,["avcC"])[0];n+="."+Ot(o[1])+Ot(o[2])+Ot(o[3]);break;case"mp4a":var l=_t(e,[i])[0],u=_t(l.subarray(28),["esds"])[0];if(u&&u.length>12){var h=4;if(3!==u[h++])break;h=Mt(u,h),h+=2;var d=u[h++];if(128&d&&(h+=2),64&d&&(h+=u[h++]),4!==u[h++])break;h=Mt(u,h);var c=u[h++];if(64!==c)break;if(n+="."+Ot(c),h+=12,5!==u[h++])break;h=Mt(u,h);var f=u[h++],g=(248&f)>>3;31===g&&(g+=1+((7&f)<<3)+((224&u[h])>>5)),n+="."+g}break;case"hvc1":case"hev1":var v=_t(r,["hvcC"])[0],m=v[1],p=["","A","B","C"][m>>6],y=31&m,E=It(v,2),T=(32&m)>>5?"H":"L",S=v[12],L=v.subarray(6,12);n+="."+p+y,n+="."+E.toString(16).toUpperCase(),n+="."+T+S;for(var A="",R=L.length;R--;){var k=L[R];(k||A)&&(A="."+k.toString(16).toUpperCase()+A)}n+=A;break;case"dvh1":case"dvhe":var b=_t(r,["dvcC"])[0],D=b[2]>>1&127,I=b[2]<<5&32|b[3]>>3&31;n+="."+Nt(D)+"."+Nt(I);break;case"vp09":var w=_t(r,["vpcC"])[0],C=w[4],_=w[5],x=w[6]>>4&15;n+="."+Nt(C)+"."+Nt(_)+"."+Nt(x);break;case"av01":var P=_t(r,["av1C"])[0],F=P[1]>>>5,M=31&P[1],O=P[2]>>>7?"H":"M",N=(64&P[2])>>6,U=(32&P[2])>>5,B=2===F&&N?U?12:10:N?10:8,G=(16&P[2])>>4,K=(8&P[2])>>3,H=(4&P[2])>>2,V=3&P[2];n+="."+F+"."+Nt(M)+O+"."+Nt(B)+"."+G+"."+K+H+V+"."+Nt(1)+"."+Nt(1)+"."+Nt(1)+".0"}return{codec:n,encrypted:a}}function Mt(t,e){for(var r=e+5;128&t[e++]&&e<r;);return e}function Ot(t){return("0"+t.toString(16).toUpperCase()).slice(-2)}function Nt(t){return(t<10?"0":"")+t}function Ut(t){var e=_t(t,["schm"])[0];if(e){var r=bt(e.subarray(4,8));if("cbcs"===r||"cenc"===r)return _t(t,["schi","tenc"])[0]}return w.error("[eme] missing 'schm' box"),null}function Bt(t){var e=It(t,0),r=8;1&e&&(r+=4),4&e&&(r+=4);for(var i=0,n=It(t,4),a=0;a<n;a++)256&e&&(i+=It(t,r),r+=4),512&e&&(r+=4),1024&e&&(r+=4),2048&e&&(r+=4);return i}function Gt(t,e){var r=new Uint8Array(t.length+e.length);return r.set(t),r.set(e,t.length),r}function Kt(t,e){var r=[],i=e.samples,n=e.timescale,a=e.id,s=!1;return _t(i,["moof"]).map((function(o){var l=o.byteOffset-8;_t(o,["traf"]).map((function(o){var u=_t(o,["tfdt"]).map((function(t){var e=t[0],r=It(t,4);return 1===e&&(r*=Math.pow(2,32),r+=It(t,8)),r/n}))[0];return void 0!==u&&(t=u),_t(o,["tfhd"]).map((function(u){var h=It(u,4),d=16777215&It(u,0),c=0,f=0!=(16&d),g=0,v=0!=(32&d),m=8;h===a&&(0!=(1&d)&&(m+=8),0!=(2&d)&&(m+=4),0!=(8&d)&&(c=It(u,m),m+=4),f&&(g=It(u,m),m+=4),v&&(m+=4),"video"===e.type&&(s=function(t){if(!t)return!1;var e=t.indexOf("."),r=e<0?t:t.substring(0,e);return"hvc1"===r||"hev1"===r||"dvh1"===r||"dvhe"===r}(e.codec)),_t(o,["trun"]).map((function(a){var o=a[0],u=16777215&It(a,0),h=0!=(1&u),d=0,f=0!=(4&u),v=0!=(256&u),m=0,p=0!=(512&u),y=0,E=0!=(1024&u),T=0!=(2048&u),S=0,L=It(a,4),A=8;h&&(d=It(a,A),A+=4),f&&(A+=4);for(var R=d+l,k=0;k<L;k++){if(v?(m=It(a,A),A+=4):m=c,p?(y=It(a,A),A+=4):y=g,E&&(A+=4),T&&(S=0===o?It(a,A):wt(a,A),A+=4),e.type===N)for(var b=0;b<y;){var D=It(i,R);Ht(s,i[R+=4])&&Vt(i.subarray(R,R+D),s?2:1,t+S/n,r),R+=D,b+=D+4}t+=m/n}})))}))}))})),r}function Ht(t,e){if(t){var r=e>>1&63;return 39===r||40===r}return 6==(31&e)}function Vt(t,e,r,i){var n=Yt(t),a=0;a+=e;for(var s=0,o=0,l=0;a<n.length;){s=0;do{if(a>=n.length)break;s+=l=n[a++]}while(255===l);o=0;do{if(a>=n.length)break;o+=l=n[a++]}while(255===l);var u=n.length-a,h=a;if(o<u)a+=o;else if(o>u){w.error("Malformed SEI payload. "+o+" is too small, only "+u+" bytes left to parse.");break}if(4===s){if(181===n[h++]){var d=Dt(n,h);if(h+=2,49===d){var c=It(n,h);if(h+=4,1195456820===c){var f=n[h++];if(3===f){var g=n[h++],v=64&g,m=v?2+3*(31&g):0,p=new Uint8Array(m);if(v){p[0]=g;for(var y=1;y<m;y++)p[y]=n[h++]}i.push({type:f,payloadType:s,pts:r,bytes:p})}}}}}else if(5===s&&o>16){for(var E=[],T=0;T<16;T++){var S=n[h++].toString(16);E.push(1==S.length?"0"+S:S),3!==T&&5!==T&&7!==T&&9!==T||E.push("-")}for(var L=o-16,A=new Uint8Array(L),R=0;R<L;R++)A[R]=n[h++];i.push({payloadType:s,pts:r,uuid:E.join(""),userData:Tt(A),userDataBytes:A})}}}function Yt(t){for(var e=t.byteLength,r=[],i=1;i<e-2;)0===t[i]&&0===t[i+1]&&3===t[i+2]?(r.push(i+2),i+=2):i++;if(0===r.length)return t;var n=e-r.length,a=new Uint8Array(n),s=0;for(i=0;i<n;s++,i++)s===r[0]&&(s++,r.shift()),a[i]=t[s];return a}function Wt(t,e,r){if(16!==t.byteLength)throw new RangeError("Invalid system id");var i,n,a;if(e){i=1,n=new Uint8Array(16*e.length);for(var s=0;s<e.length;s++){var o=e[s];if(16!==o.byteLength)throw new RangeError("Invalid key");n.set(o,16*s)}}else i=0,n=new Uint8Array;i>0?(a=new Uint8Array(4),e.length>0&&new DataView(a.buffer).setUint32(0,e.length,!1)):a=new Uint8Array;var l=new Uint8Array(4);return r&&r.byteLength>0&&new DataView(l.buffer).setUint32(0,r.byteLength,!1),function(t){for(var e=arguments.length,r=new Array(e>1?e-1:0),i=1;i<e;i++)r[i-1]=arguments[i];for(var n=r.length,a=8,s=n;s--;)a+=r[s].byteLength;var o=new Uint8Array(a);for(o[0]=a>>24&255,o[1]=a>>16&255,o[2]=a>>8&255,o[3]=255&a,o.set(t,4),s=0,a=8;s<n;s++)o.set(r[s],a),a+=r[s].byteLength;return o}([112,115,115,104],new Uint8Array([i,0,0,0]),t,a,n,l,r||new Uint8Array)}var jt={},qt=function(){function t(t,e,r,i,n){void 0===i&&(i=[1]),void 0===n&&(n=null),this.uri=void 0,this.method=void 0,this.keyFormat=void 0,this.keyFormatVersions=void 0,this.encrypted=void 0,this.isCommonEncryption=void 0,this.iv=null,this.key=null,this.keyId=null,this.pssh=null,this.method=t,this.uri=e,this.keyFormat=r,this.keyFormatVersions=i,this.iv=n,this.encrypted=!!t&&"NONE"!==t,this.isCommonEncryption=this.encrypted&&"AES-128"!==t}t.clearKeyUriToKeyIdMap=function(){jt={}};var e=t.prototype;return e.isSupported=function(){if(this.method){if("AES-128"===this.method||"NONE"===this.method)return!0;if("identity"===this.keyFormat)return"SAMPLE-AES"===this.method;switch(this.keyFormat){case z:case J:case Q:case X:return-1!==["ISO-23001-7","SAMPLE-AES","SAMPLE-AES-CENC","SAMPLE-AES-CTR"].indexOf(this.method)}}return!1},e.getDecryptData=function(e){if(!this.encrypted||!this.uri)return null;if("AES-128"===this.method&&this.uri&&!this.iv){"number"!=typeof e&&("AES-128"!==this.method||this.iv||w.warn('missing IV for initialization segment with method="'+this.method+'" - compliance issue'),e=0);var r=function(t){for(var e=new Uint8Array(16),r=12;r<16;r++)e[r]=t>>8*(15-r)&255;return e}(e);return new t(this.method,this.uri,"identity",this.keyFormatVersions,r)}var i=Y(this.uri);if(i)switch(this.keyFormat){case J:this.pssh=i,i.length>=22&&(this.keyId=i.subarray(i.length-22,i.length-6));break;case Q:var n=new Uint8Array([154,4,240,121,152,64,66,134,171,146,230,91,224,136,95,149]);this.pssh=Wt(n,null,i);var a=new Uint16Array(i.buffer,i.byteOffset,i.byteLength/2),s=String.fromCharCode.apply(null,Array.from(a)),o=s.substring(s.indexOf("<"),s.length),l=(new DOMParser).parseFromString(o,"text/xml").getElementsByTagName("KID")[0];if(l){var u=l.childNodes[0]?l.childNodes[0].nodeValue:l.getAttribute("VALUE");if(u){var h=V(u).subarray(0,16);!function(t){var e=function(t,e,r){var i=t[e];t[e]=t[r],t[r]=i};e(t,0,3),e(t,1,2),e(t,4,5),e(t,6,7)}(h),this.keyId=h}}break;default:var d=i.subarray(0,16);if(16!==d.length){var c=new Uint8Array(16);c.set(d,16-d.length),d=c}this.keyId=d}if(!this.keyId||16!==this.keyId.byteLength){var f=jt[this.uri];if(!f){var g=Object.keys(jt).length%Number.MAX_SAFE_INTEGER;f=new Uint8Array(16),new DataView(f.buffer,12,4).setUint32(0,g),jt[this.uri]=f}this.keyId=f}return this},t}(),Xt=/\{\$([a-zA-Z0-9-_]+)\}/g;function zt(t){return Xt.test(t)}function Qt(t,e,r){if(null!==t.variableList||t.hasVariableRefs)for(var i=r.length;i--;){var n=r[i],a=e[n];a&&(e[n]=Jt(t,a))}}function Jt(t,e){if(null!==t.variableList||t.hasVariableRefs){var r=t.variableList;return e.replace(Xt,(function(e){var i=e.substring(2,e.length-1),n=null==r?void 0:r[i];return void 0===n?(t.playlistParsingError||(t.playlistParsingError=new Error('Missing preceding EXT-X-DEFINE tag for Variable Reference: "'+i+'"')),e):n}))}return e}function $t(t,e,r){var i,n,a=t.variableList;if(a||(t.variableList=a={}),"QUERYPARAM"in e){i=e.QUERYPARAM;try{var s=new self.URL(r).searchParams;if(!s.has(i))throw new Error('"'+i+'" does not match any query parameter in URI: "'+r+'"');n=s.get(i)}catch(e){t.playlistParsingError||(t.playlistParsingError=new Error("EXT-X-DEFINE QUERYPARAM: "+e.message))}}else i=e.NAME,n=e.VALUE;i in a?t.playlistParsingError||(t.playlistParsingError=new Error('EXT-X-DEFINE duplicate Variable Name declarations: "'+i+'"')):a[i]=n||""}function Zt(t,e,r){var i=e.IMPORT;if(r&&i in r){var n=t.variableList;n||(t.variableList=n={}),n[i]=r[i]}else t.playlistParsingError||(t.playlistParsingError=new Error('EXT-X-DEFINE IMPORT attribute not found in Multivariant Playlist: "'+i+'"'))}function te(t){if(void 0===t&&(t=!0),"undefined"!=typeof self)return(t||!self.MediaSource)&&self.ManagedMediaSource||self.MediaSource||self.WebKitMediaSource}var ee={audio:{a3ds:1,"ac-3":.95,"ac-4":1,alac:.9,alaw:1,dra1:1,"dts+":1,"dts-":1,dtsc:1,dtse:1,dtsh:1,"ec-3":.9,enca:1,fLaC:.9,flac:.9,FLAC:.9,g719:1,g726:1,m4ae:1,mha1:1,mha2:1,mhm1:1,mhm2:1,mlpa:1,mp4a:1,"raw ":1,Opus:1,opus:1,samr:1,sawb:1,sawp:1,sevc:1,sqcp:1,ssmv:1,twos:1,ulaw:1},video:{avc1:1,avc2:1,avc3:1,avc4:1,avcp:1,av01:.8,drac:1,dva1:1,dvav:1,dvh1:.7,dvhe:.7,encv:1,hev1:.75,hvc1:.75,mjp2:1,mp4v:1,mvc1:1,mvc2:1,mvc3:1,mvc4:1,resv:1,rv60:1,s263:1,svc1:1,svc2:1,"vc-1":1,vp08:1,vp09:.9},text:{stpp:1,wvtt:1}};function re(t,e,r){return void 0===r&&(r=!0),!t.split(",").some((function(t){return!ie(t,e,r)}))}function ie(t,e,r){var i;void 0===r&&(r=!0);var n=te(r);return null!=(i=null==n?void 0:n.isTypeSupported(ne(t,e)))&&i}function ne(t,e){return e+'/mp4;codecs="'+t+'"'}function ae(t){if(t){var e=t.substring(0,4);return ee.video[e]}return 2}function se(t){return t.split(",").reduce((function(t,e){var r=ee.video[e];return r?(2*r+t)/(t?3:2):(ee.audio[e]+t)/(t?2:1)}),0)}var oe={},le=/flac|opus/i;function ue(t,e){return void 0===e&&(e=!0),t.replace(le,(function(t){return function(t,e){if(void 0===e&&(e=!0),oe[t])return oe[t];for(var r={flac:["flac","fLaC","FLAC"],opus:["opus","Opus"]}[t],i=0;i<r.length;i++)if(ie(r[i],"audio",e))return oe[t]=r[i],r[i];return t}(t.toLowerCase(),e)}))}function he(t,e){return t&&"mp4a"!==t?t:e}var de=/#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-(SESSION-DATA|SESSION-KEY|DEFINE|CONTENT-STEERING|START):([^\r\n]*)[\r\n]+/g,ce=/#EXT-X-MEDIA:(.*)/g,fe=/^#EXT(?:INF|-X-TARGETDURATION):/m,ge=new RegExp([/#EXTINF:\s*(\d*(?:\.\d+)?)(?:,(.*)\s+)?/.source,/(?!#) *(\S[\S ]*)/.source,/#EXT-X-BYTERANGE:*(.+)/.source,/#EXT-X-PROGRAM-DATE-TIME:(.+)/.source,/#.*/.source].join("|"),"g"),ve=new RegExp([/#(EXTM3U)/.source,/#EXT-X-(DATERANGE|DEFINE|KEY|MAP|PART|PART-INF|PLAYLIST-TYPE|PRELOAD-HINT|RENDITION-REPORT|SERVER-CONTROL|SKIP|START):(.+)/.source,/#EXT-X-(BITRATE|DISCONTINUITY-SEQUENCE|MEDIA-SEQUENCE|TARGETDURATION|VERSION): *(\d+)/.source,/#EXT-X-(DISCONTINUITY|ENDLIST|GAP|INDEPENDENT-SEGMENTS)/.source,/(#)([^:]*):(.*)/.source,/(#)(.*)(?:.*)\r?\n?/.source].join("|")),me=function(){function t(){}return t.findGroup=function(t,e){for(var r=0;r<t.length;r++){var i=t[r];if(i.id===e)return i}},t.resolve=function(t,e){return p.buildAbsoluteURL(e,t,{alwaysNormalize:!0})},t.isMediaPlaylist=function(t){return fe.test(t)},t.parseMasterPlaylist=function(e,r){var i,n={contentSteering:null,levels:[],playlistParsingError:null,sessionData:null,sessionKeys:null,startTimeOffset:null,variableList:null,hasVariableRefs:zt(e)},a=[];for(de.lastIndex=0;null!=(i=de.exec(e));)if(i[1]){var s,o=new x(i[1]);Qt(n,o,["CODECS","SUPPLEMENTAL-CODECS","ALLOWED-CPC","PATHWAY-ID","STABLE-VARIANT-ID","AUDIO","VIDEO","SUBTITLES","CLOSED-CAPTIONS","NAME"]);var l=Jt(n,i[2]),u={attrs:o,bitrate:o.decimalInteger("BANDWIDTH")||o.decimalInteger("AVERAGE-BANDWIDTH"),name:o.NAME,url:t.resolve(l,r)},h=o.decimalResolution("RESOLUTION");h&&(u.width=h.width,u.height=h.height),Ee(o.CODECS,u),null!=(s=u.unknownCodecs)&&s.length||a.push(u),n.levels.push(u)}else if(i[3]){var d=i[3],c=i[4];switch(d){case"SESSION-DATA":var f=new x(c);Qt(n,f,["DATA-ID","LANGUAGE","VALUE","URI"]);var g=f["DATA-ID"];g&&(null===n.sessionData&&(n.sessionData={}),n.sessionData[g]=f);break;case"SESSION-KEY":var v=pe(c,r,n);v.encrypted&&v.isSupported()?(null===n.sessionKeys&&(n.sessionKeys=[]),n.sessionKeys.push(v)):w.warn('[Keys] Ignoring invalid EXT-X-SESSION-KEY tag: "'+c+'"');break;case"DEFINE":var m=new x(c);Qt(n,m,["NAME","VALUE","QUERYPARAM"]),$t(n,m,r);break;case"CONTENT-STEERING":var p=new x(c);Qt(n,p,["SERVER-URI","PATHWAY-ID"]),n.contentSteering={uri:t.resolve(p["SERVER-URI"],r),pathwayId:p["PATHWAY-ID"]||"."};break;case"START":n.startTimeOffset=ye(c)}}var y=a.length>0&&a.length<n.levels.length;return n.levels=y?a:n.levels,0===n.levels.length&&(n.playlistParsingError=new Error("no levels found in manifest")),n},t.parseMasterPlaylistMedia=function(e,r,i){var n,a={},s=i.levels,o={AUDIO:s.map((function(t){return{id:t.attrs.AUDIO,audioCodec:t.audioCodec}})),SUBTITLES:s.map((function(t){return{id:t.attrs.SUBTITLES,textCodec:t.textCodec}})),"CLOSED-CAPTIONS":[]},l=0;for(ce.lastIndex=0;null!==(n=ce.exec(e));){var u=new x(n[1]),h=u.TYPE;if(h){var d=o[h],c=a[h]||[];a[h]=c,Qt(i,u,["URI","GROUP-ID","LANGUAGE","ASSOC-LANGUAGE","STABLE-RENDITION-ID","NAME","INSTREAM-ID","CHARACTERISTICS","CHANNELS"]);var f=u.LANGUAGE,g=u["ASSOC-LANGUAGE"],v=u.CHANNELS,m=u.CHARACTERISTICS,p=u["INSTREAM-ID"],y={attrs:u,bitrate:0,id:l++,groupId:u["GROUP-ID"]||"",name:u.NAME||f||"",type:h,default:u.bool("DEFAULT"),autoselect:u.bool("AUTOSELECT"),forced:u.bool("FORCED"),lang:f,url:u.URI?t.resolve(u.URI,r):""};if(g&&(y.assocLang=g),v&&(y.channels=v),m&&(y.characteristics=m),p&&(y.instreamId=p),null!=d&&d.length){var E=t.findGroup(d,y.groupId)||d[0];Te(y,E,"audioCodec"),Te(y,E,"textCodec")}c.push(y)}}return a},t.parseLevelPlaylist=function(t,e,r,i,n,a){var s,l,u,h=new H(e),d=h.fragments,c=null,f=0,g=0,v=0,m=0,p=null,E=new G(i,e),T=-1,S=!1,L=null;for(ge.lastIndex=0,h.m3u8=t,h.hasVariableRefs=zt(t);null!==(s=ge.exec(t));){S&&(S=!1,(E=new G(i,e)).start=v,E.sn=f,E.cc=m,E.level=r,c&&(E.initSegment=c,E.rawProgramDateTime=c.rawProgramDateTime,c.rawProgramDateTime=null,L&&(E.setByteRange(L),L=null)));var A=s[1];if(A){E.duration=parseFloat(A);var R=(" "+s[2]).slice(1);E.title=R||null,E.tagList.push(R?["INF",A,R]:["INF",A])}else if(s[3]){if(y(E.duration)){E.start=v,u&&Ae(E,u,h),E.sn=f,E.level=r,E.cc=m,d.push(E);var k=(" "+s[3]).slice(1);E.relurl=Jt(h,k),Se(E,p),p=E,v+=E.duration,f++,g=0,S=!0}}else if(s[4]){var b=(" "+s[4]).slice(1);p?E.setByteRange(b,p):E.setByteRange(b)}else if(s[5])E.rawProgramDateTime=(" "+s[5]).slice(1),E.tagList.push(["PROGRAM-DATE-TIME",E.rawProgramDateTime]),-1===T&&(T=d.length);else{if(!(s=s[0].match(ve))){w.warn("No matches on slow regex match for level playlist!");continue}for(l=1;l<s.length&&void 0===s[l];l++);var D=(" "+s[l]).slice(1),I=(" "+s[l+1]).slice(1),C=s[l+2]?(" "+s[l+2]).slice(1):"";switch(D){case"PLAYLIST-TYPE":h.type=I.toUpperCase();break;case"MEDIA-SEQUENCE":f=h.startSN=parseInt(I);break;case"SKIP":var _=new x(I);Qt(h,_,["RECENTLY-REMOVED-DATERANGES"]);var P=_.decimalInteger("SKIPPED-SEGMENTS");if(y(P)){h.skippedSegments=P;for(var M=P;M--;)d.unshift(null);f+=P}var O=_.enumeratedString("RECENTLY-REMOVED-DATERANGES");O&&(h.recentlyRemovedDateranges=O.split("\t"));break;case"TARGETDURATION":h.targetduration=Math.max(parseInt(I),1);break;case"VERSION":h.version=parseInt(I);break;case"INDEPENDENT-SEGMENTS":case"EXTM3U":break;case"ENDLIST":h.live=!1;break;case"#":(I||C)&&E.tagList.push(C?[I,C]:[I]);break;case"DISCONTINUITY":m++,E.tagList.push(["DIS"]);break;case"GAP":E.gap=!0,E.tagList.push([D]);break;case"BITRATE":E.tagList.push([D,I]);break;case"DATERANGE":var N=new x(I);Qt(h,N,["ID","CLASS","START-DATE","END-DATE","SCTE35-CMD","SCTE35-OUT","SCTE35-IN"]),Qt(h,N,N.clientAttrs);var U=new F(N,h.dateRanges[N.ID]);U.isValid||h.skippedSegments?h.dateRanges[U.id]=U:w.warn('Ignoring invalid DATERANGE tag: "'+I+'"'),E.tagList.push(["EXT-X-DATERANGE",I]);break;case"DEFINE":var B=new x(I);Qt(h,B,["NAME","VALUE","IMPORT","QUERYPARAM"]),"IMPORT"in B?Zt(h,B,a):$t(h,B,e);break;case"DISCONTINUITY-SEQUENCE":m=parseInt(I);break;case"KEY":var V=pe(I,e,h);if(V.isSupported()){if("NONE"===V.method){u=void 0;break}u||(u={}),u[V.keyFormat]&&(u=o({},u)),u[V.keyFormat]=V}else w.warn('[Keys] Ignoring invalid EXT-X-KEY tag: "'+I+'"');break;case"START":h.startTimeOffset=ye(I);break;case"MAP":var Y=new x(I);if(Qt(h,Y,["BYTERANGE","URI"]),E.duration){var W=new G(i,e);Le(W,Y,r,u),c=W,E.initSegment=c,c.rawProgramDateTime&&!E.rawProgramDateTime&&(E.rawProgramDateTime=c.rawProgramDateTime)}else{var j=E.byteRangeEndOffset;if(j){var q=E.byteRangeStartOffset;L=j-q+"@"+q}else L=null;Le(E,Y,r,u),c=E,S=!0}break;case"SERVER-CONTROL":var X=new x(I);h.canBlockReload=X.bool("CAN-BLOCK-RELOAD"),h.canSkipUntil=X.optionalFloat("CAN-SKIP-UNTIL",0),h.canSkipDateRanges=h.canSkipUntil>0&&X.bool("CAN-SKIP-DATERANGES"),h.partHoldBack=X.optionalFloat("PART-HOLD-BACK",0),h.holdBack=X.optionalFloat("HOLD-BACK",0);break;case"PART-INF":var z=new x(I);h.partTarget=z.decimalFloatingPoint("PART-TARGET");break;case"PART":var Q=h.partList;Q||(Q=h.partList=[]);var J=g>0?Q[Q.length-1]:void 0,$=g++,Z=new x(I);Qt(h,Z,["BYTERANGE","URI"]);var tt=new K(Z,E,e,$,J);Q.push(tt),E.duration+=tt.duration;break;case"PRELOAD-HINT":var et=new x(I);Qt(h,et,["URI"]),h.preloadHint=et;break;case"RENDITION-REPORT":var rt=new x(I);Qt(h,rt,["URI"]),h.renditionReports=h.renditionReports||[],h.renditionReports.push(rt);break;default:w.warn("line parsed but not handled: "+s)}}}p&&!p.relurl?(d.pop(),v-=p.duration,h.partList&&(h.fragmentHint=p)):h.partList&&(Se(E,p),E.cc=m,h.fragmentHint=E,u&&Ae(E,u,h));var it=d.length,nt=d[0],at=d[it-1];if((v+=h.skippedSegments*h.targetduration)>0&&it&&at){h.averagetargetduration=v/it;var st=at.sn;h.endSN="initSegment"!==st?st:0,h.live||(at.endList=!0),nt&&(h.startCC=nt.cc)}else h.endSN=0,h.startCC=0;return h.fragmentHint&&(v+=h.fragmentHint.duration),h.totalduration=v,h.endCC=m,T>0&&function(t,e){for(var r=t[e],i=e;i--;){var n=t[i];if(!n)return;n.programDateTime=r.programDateTime-1e3*n.duration,r=n}}(d,T),h},t}();function pe(t,e,r){var i,n,a=new x(t);Qt(r,a,["KEYFORMAT","KEYFORMATVERSIONS","URI","IV","URI"]);var s=null!=(i=a.METHOD)?i:"",o=a.URI,l=a.hexadecimalInteger("IV"),u=a.KEYFORMATVERSIONS,h=null!=(n=a.KEYFORMAT)?n:"identity";o&&a.IV&&!l&&w.error("Invalid IV: "+a.IV);var d=o?me.resolve(o,e):"",c=(u||"1").split("/").map(Number).filter(Number.isFinite);return new qt(s,d,h,c,l)}function ye(t){var e=new x(t).decimalFloatingPoint("TIME-OFFSET");return y(e)?e:null}function Ee(t,e){var r=(t||"").split(/[ ,]+/).filter((function(t){return t}));["video","audio","text"].forEach((function(t){var i=r.filter((function(e){return function(t,e){var r=ee[e];return!!r&&!!r[t.slice(0,4)]}(e,t)}));i.length&&(e[t+"Codec"]=i.join(","),r=r.filter((function(t){return-1===i.indexOf(t)})))})),e.unknownCodecs=r}function Te(t,e,r){var i=e[r];i&&(t[r]=i)}function Se(t,e){t.rawProgramDateTime?t.programDateTime=Date.parse(t.rawProgramDateTime):null!=e&&e.programDateTime&&(t.programDateTime=e.endProgramDateTime),y(t.programDateTime)||(t.programDateTime=null,t.rawProgramDateTime=null)}function Le(t,e,r,i){t.relurl=e.URI,e.BYTERANGE&&t.setByteRange(e.BYTERANGE),t.level=r,t.sn="initSegment",i&&(t.levelkeys=i),t.initSegment=null}function Ae(t,e,r){t.levelkeys=e;var i=r.encryptedFragments;i.length&&i[i.length-1].levelkeys===e||!Object.keys(e).some((function(t){return e[t].isCommonEncryption}))||i.push(t)}var Re="manifest",ke="level",be="audioTrack",De="subtitleTrack",Ie="main",we="audio",Ce="subtitle";function _e(t){switch(t.type){case be:return we;case De:return Ce;default:return Ie}}function xe(t,e){var r=t.url;return void 0!==r&&0!==r.indexOf("data:")||(r=e.url),r}var Pe=function(){function t(t){this.hls=void 0,this.loaders=Object.create(null),this.variableList=null,this.hls=t,this.registerListeners()}var e=t.prototype;return e.startLoad=function(t){},e.stopLoad=function(){this.destroyInternalLoaders()},e.registerListeners=function(){var t=this.hls;t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.LEVEL_LOADING,this.onLevelLoading,this),t.on(S.AUDIO_TRACK_LOADING,this.onAudioTrackLoading,this),t.on(S.SUBTITLE_TRACK_LOADING,this.onSubtitleTrackLoading,this)},e.unregisterListeners=function(){var t=this.hls;t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.LEVEL_LOADING,this.onLevelLoading,this),t.off(S.AUDIO_TRACK_LOADING,this.onAudioTrackLoading,this),t.off(S.SUBTITLE_TRACK_LOADING,this.onSubtitleTrackLoading,this)},e.createInternalLoader=function(t){var e=this.hls.config,r=e.pLoader,i=e.loader,n=new(r||i)(e);return this.loaders[t.type]=n,n},e.getInternalLoader=function(t){return this.loaders[t.type]},e.resetInternalLoader=function(t){this.loaders[t]&&delete this.loaders[t]},e.destroyInternalLoaders=function(){for(var t in this.loaders){var e=this.loaders[t];e&&e.destroy(),this.resetInternalLoader(t)}},e.destroy=function(){this.variableList=null,this.unregisterListeners(),this.destroyInternalLoaders()},e.onManifestLoading=function(t,e){var r=e.url;this.variableList=null,this.load({id:null,level:0,responseType:"text",type:Re,url:r,deliveryDirectives:null})},e.onLevelLoading=function(t,e){var r=e.id,i=e.level,n=e.pathwayId,a=e.url,s=e.deliveryDirectives;this.load({id:r,level:i,pathwayId:n,responseType:"text",type:ke,url:a,deliveryDirectives:s})},e.onAudioTrackLoading=function(t,e){var r=e.id,i=e.groupId,n=e.url,a=e.deliveryDirectives;this.load({id:r,groupId:i,level:null,responseType:"text",type:be,url:n,deliveryDirectives:a})},e.onSubtitleTrackLoading=function(t,e){var r=e.id,i=e.groupId,n=e.url,a=e.deliveryDirectives;this.load({id:r,groupId:i,level:null,responseType:"text",type:De,url:n,deliveryDirectives:a})},e.load=function(t){var e,r,i,n=this,a=this.hls.config,s=this.getInternalLoader(t);if(s){var l=s.context;if(l&&l.url===t.url&&l.level===t.level)return void w.trace("[playlist-loader]: playlist request ongoing");w.log("[playlist-loader]: aborting previous loader for type: "+t.type),s.abort()}if(r=t.type===Re?a.manifestLoadPolicy.default:o({},a.playlistLoadPolicy.default,{timeoutRetry:null,errorRetry:null}),s=this.createInternalLoader(t),y(null==(e=t.deliveryDirectives)?void 0:e.part)&&(t.type===ke&&null!==t.level?i=this.hls.levels[t.level].details:t.type===be&&null!==t.id?i=this.hls.audioTracks[t.id].details:t.type===De&&null!==t.id&&(i=this.hls.subtitleTracks[t.id].details),i)){var u=i.partTarget,h=i.targetduration;if(u&&h){var d=1e3*Math.max(3*u,.8*h);r=o({},r,{maxTimeToFirstByteMs:Math.min(d,r.maxTimeToFirstByteMs),maxLoadTimeMs:Math.min(d,r.maxTimeToFirstByteMs)})}}var c=r.errorRetry||r.timeoutRetry||{},f={loadPolicy:r,timeout:r.maxLoadTimeMs,maxRetry:c.maxNumRetry||0,retryDelay:c.retryDelayMs||0,maxRetryDelay:c.maxRetryDelayMs||0},g={onSuccess:function(t,e,r,i){var a=n.getInternalLoader(r);n.resetInternalLoader(r.type);var s=t.data;0===s.indexOf("#EXTM3U")?(e.parsing.start=performance.now(),me.isMediaPlaylist(s)?n.handleTrackOrLevelPlaylist(t,e,r,i||null,a):n.handleMasterPlaylist(t,e,r,i)):n.handleManifestParsingError(t,r,new Error("no EXTM3U delimiter"),i||null,e)},onError:function(t,e,r,i){n.handleNetworkError(e,r,!1,t,i)},onTimeout:function(t,e,r){n.handleNetworkError(e,r,!0,void 0,t)}};s.load(t,f,g)},e.handleMasterPlaylist=function(t,e,r,i){var n=this.hls,a=t.data,s=xe(t,r),o=me.parseMasterPlaylist(a,s);if(o.playlistParsingError)this.handleManifestParsingError(t,r,o.playlistParsingError,i,e);else{var l=o.contentSteering,u=o.levels,h=o.sessionData,d=o.sessionKeys,c=o.startTimeOffset,f=o.variableList;this.variableList=f;var g=me.parseMasterPlaylistMedia(a,s,o),v=g.AUDIO,m=void 0===v?[]:v,p=g.SUBTITLES,y=g["CLOSED-CAPTIONS"];m.length&&(m.some((function(t){return!t.url}))||!u[0].audioCodec||u[0].attrs.AUDIO||(w.log("[playlist-loader]: audio codec signaled in quality level, but no embedded audio track signaled, create one"),m.unshift({type:"main",name:"main",groupId:"main",default:!1,autoselect:!1,forced:!1,id:-1,attrs:new x({}),bitrate:0,url:""}))),n.trigger(S.MANIFEST_LOADED,{levels:u,audioTracks:m,subtitles:p,captions:y,contentSteering:l,url:s,stats:e,networkDetails:i,sessionData:h,sessionKeys:d,startTimeOffset:c,variableList:f})}},e.handleTrackOrLevelPlaylist=function(t,e,r,i,n){var a=this.hls,s=r.id,o=r.level,l=r.type,u=xe(t,r),h=y(o)?o:y(s)?s:0,d=_e(r),c=me.parseLevelPlaylist(t.data,u,h,d,0,this.variableList);if(l===Re){var f={attrs:new x({}),bitrate:0,details:c,name:"",url:u};a.trigger(S.MANIFEST_LOADED,{levels:[f],audioTracks:[],url:u,stats:e,networkDetails:i,sessionData:null,sessionKeys:null,contentSteering:null,startTimeOffset:null,variableList:null})}e.parsing.end=performance.now(),r.levelDetails=c,this.handlePlaylistLoaded(c,t,e,r,i,n)},e.handleManifestParsingError=function(t,e,r,i,n){this.hls.trigger(S.ERROR,{type:L.NETWORK_ERROR,details:A.MANIFEST_PARSING_ERROR,fatal:e.type===Re,url:t.url,err:r,error:r,reason:r.message,response:t,context:e,networkDetails:i,stats:n})},e.handleNetworkError=function(t,e,r,n,a){void 0===r&&(r=!1);var s="A network "+(r?"timeout":"error"+(n?" (status "+n.code+")":""))+" occurred while loading "+t.type;t.type===ke?s+=": "+t.level+" id: "+t.id:t.type!==be&&t.type!==De||(s+=" id: "+t.id+' group-id: "'+t.groupId+'"');var o=new Error(s);w.warn("[playlist-loader]: "+s);var l=A.UNKNOWN,u=!1,h=this.getInternalLoader(t);switch(t.type){case Re:l=r?A.MANIFEST_LOAD_TIMEOUT:A.MANIFEST_LOAD_ERROR,u=!0;break;case ke:l=r?A.LEVEL_LOAD_TIMEOUT:A.LEVEL_LOAD_ERROR,u=!1;break;case be:l=r?A.AUDIO_TRACK_LOAD_TIMEOUT:A.AUDIO_TRACK_LOAD_ERROR,u=!1;break;case De:l=r?A.SUBTITLE_TRACK_LOAD_TIMEOUT:A.SUBTITLE_LOAD_ERROR,u=!1}h&&this.resetInternalLoader(t.type);var d={type:L.NETWORK_ERROR,details:l,fatal:u,url:t.url,loader:h,context:t,error:o,networkDetails:e,stats:a};if(n){var c=(null==e?void 0:e.url)||t.url;d.response=i({url:c,data:void 0},n)}this.hls.trigger(S.ERROR,d)},e.handlePlaylistLoaded=function(t,e,r,i,n,a){var s=this.hls,o=i.type,l=i.level,u=i.id,h=i.groupId,d=i.deliveryDirectives,c=xe(e,i),f=_e(i),g="number"==typeof i.level&&f===Ie?l:void 0;if(t.fragments.length){t.targetduration||(t.playlistParsingError=new Error("Missing Target Duration"));var v=t.playlistParsingError;if(v)s.trigger(S.ERROR,{type:L.NETWORK_ERROR,details:A.LEVEL_PARSING_ERROR,fatal:!1,url:c,error:v,reason:v.message,response:e,context:i,level:g,parent:f,networkDetails:n,stats:r});else switch(t.live&&a&&(a.getCacheAge&&(t.ageHeader=a.getCacheAge()||0),a.getCacheAge&&!isNaN(t.ageHeader)||(t.ageHeader=0)),o){case Re:case ke:s.trigger(S.LEVEL_LOADED,{details:t,level:g||0,id:u||0,stats:r,networkDetails:n,deliveryDirectives:d});break;case be:s.trigger(S.AUDIO_TRACK_LOADED,{details:t,id:u||0,groupId:h||"",stats:r,networkDetails:n,deliveryDirectives:d});break;case De:s.trigger(S.SUBTITLE_TRACK_LOADED,{details:t,id:u||0,groupId:h||"",stats:r,networkDetails:n,deliveryDirectives:d})}}else{var m=new Error("No Segments found in Playlist");s.trigger(S.ERROR,{type:L.NETWORK_ERROR,details:A.LEVEL_EMPTY_ERROR,fatal:!1,url:c,error:m,reason:m.message,response:e,context:i,level:g,parent:f,networkDetails:n,stats:r})}},t}();function Fe(t,e){var r;try{r=new Event("addtrack")}catch(t){(r=document.createEvent("Event")).initEvent("addtrack",!1,!1)}r.track=t,e.dispatchEvent(r)}function Me(t,e){var r=t.mode;if("disabled"===r&&(t.mode="hidden"),t.cues&&!t.cues.getCueById(e.id))try{if(t.addCue(e),!t.cues.getCueById(e.id))throw new Error("addCue is failed for: "+e)}catch(r){w.debug("[texttrack-utils]: "+r);try{var i=new self.TextTrackCue(e.startTime,e.endTime,e.text);i.id=e.id,t.addCue(i)}catch(t){w.debug("[texttrack-utils]: Legacy TextTrackCue fallback failed: "+t)}}"disabled"===r&&(t.mode=r)}function Oe(t){var e=t.mode;if("disabled"===e&&(t.mode="hidden"),t.cues)for(var r=t.cues.length;r--;)t.removeCue(t.cues[r]);"disabled"===e&&(t.mode=e)}function Ne(t,e,r,i){var n=t.mode;if("disabled"===n&&(t.mode="hidden"),t.cues&&t.cues.length>0)for(var a=function(t,e,r){var i=[],n=function(t,e){if(e<t[0].startTime)return 0;var r=t.length-1;if(e>t[r].endTime)return-1;for(var i=0,n=r;i<=n;){var a=Math.floor((n+i)/2);if(e<t[a].startTime)n=a-1;else{if(!(e>t[a].startTime&&i<r))return a;i=a+1}}return t[i].startTime-e<e-t[n].startTime?i:n}(t,e);if(n>-1)for(var a=n,s=t.length;a<s;a++){var o=t[a];if(o.startTime>=e&&o.endTime<=r)i.push(o);else if(o.startTime>r)return i}return i}(t.cues,e,r),s=0;s<a.length;s++)i&&!i(a[s])||t.removeCue(a[s]);"disabled"===n&&(t.mode=n)}function Ue(t){for(var e=[],r=0;r<t.length;r++){var i=t[r];"subtitles"!==i.kind&&"captions"!==i.kind||!i.label||e.push(t[r])}return e}var Be="org.id3",Ge="com.apple.quicktime.HLS",Ke="https://aomedia.org/emsg/ID3";function He(){if("undefined"!=typeof self)return self.VTTCue||self.TextTrackCue}function Ve(t,e,r,n,a){var s=new t(e,r,"");try{s.value=n,a&&(s.type=a)}catch(o){s=new t(e,r,JSON.stringify(a?i({type:a},n):n))}return s}var Ye=function(){var t=He();try{t&&new t(0,Number.POSITIVE_INFINITY,"")}catch(t){return Number.MAX_VALUE}return Number.POSITIVE_INFINITY}();function We(t,e){return t.getTime()/1e3-e}var je=function(){function t(t){this.hls=void 0,this.id3Track=null,this.media=null,this.dateRangeCuesAppended={},this.hls=t,this._registerListeners()}var e=t.prototype;return e.destroy=function(){this._unregisterListeners(),this.id3Track=null,this.media=null,this.dateRangeCuesAppended={},this.hls=null},e._registerListeners=function(){var t=this.hls;t.on(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.on(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.FRAG_PARSING_METADATA,this.onFragParsingMetadata,this),t.on(S.BUFFER_FLUSHING,this.onBufferFlushing,this),t.on(S.LEVEL_UPDATED,this.onLevelUpdated,this)},e._unregisterListeners=function(){var t=this.hls;t.off(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.off(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.FRAG_PARSING_METADATA,this.onFragParsingMetadata,this),t.off(S.BUFFER_FLUSHING,this.onBufferFlushing,this),t.off(S.LEVEL_UPDATED,this.onLevelUpdated,this)},e.onMediaAttached=function(t,e){this.media=e.media},e.onMediaDetaching=function(){this.id3Track&&(Oe(this.id3Track),this.id3Track=null,this.media=null,this.dateRangeCuesAppended={})},e.onManifestLoading=function(){this.dateRangeCuesAppended={}},e.createTrack=function(t){var e=this.getID3Track(t.textTracks);return e.mode="hidden",e},e.getID3Track=function(t){if(this.media){for(var e=0;e<t.length;e++){var r=t[e];if("metadata"===r.kind&&"id3"===r.label)return Fe(r,this.media),r}return this.media.addTextTrack("metadata","id3")}},e.onFragParsingMetadata=function(t,e){if(this.media){var r=this.hls.config,i=r.enableEmsgMetadataCues,n=r.enableID3MetadataCues;if(i||n){var a=e.samples;this.id3Track||(this.id3Track=this.createTrack(this.media));var s=He();if(s)for(var o=0;o<a.length;o++){var l=a[o].type;if((l!==Ke||i)&&n){var u=gt(a[o].data);if(u){var h=a[o].pts,d=h+a[o].duration;d>Ye&&(d=Ye),d-h<=0&&(d=h+.25);for(var c=0;c<u.length;c++){var f=u[c];if(!ct(f)){this.updateId3CueEnds(h,l);var g=Ve(s,h,d,f,l);g&&this.id3Track.addCue(g)}}}}}}}},e.updateId3CueEnds=function(t,e){var r,i=null==(r=this.id3Track)?void 0:r.cues;if(i)for(var n=i.length;n--;){var a=i[n];a.type===e&&a.startTime<t&&a.endTime===Ye&&(a.endTime=t)}},e.onBufferFlushing=function(t,e){var r=e.startOffset,i=e.endOffset,n=e.type,a=this.id3Track,s=this.hls;if(s){var o=s.config,l=o.enableEmsgMetadataCues,u=o.enableID3MetadataCues;a&&(l||u)&&Ne(a,r,i,"audio"===n?function(t){return t.type===Be&&u}:"video"===n?function(t){return t.type===Ke&&l}:function(t){return t.type===Be&&u||t.type===Ke&&l})}},e.onLevelUpdated=function(t,e){var r=this,i=e.details;if(this.media&&i.hasProgramDateTime&&this.hls.config.enableDateRangeMetadataCues){var n=this.dateRangeCuesAppended,a=this.id3Track,s=i.dateRanges,o=Object.keys(s);if(a)for(var l=Object.keys(n).filter((function(t){return!o.includes(t)})),u=function(){var t=l[h];Object.keys(n[t].cues).forEach((function(e){a.removeCue(n[t].cues[e])})),delete n[t]},h=l.length;h--;)u();var d=i.fragments[i.fragments.length-1];if(0!==o.length&&y(null==d?void 0:d.programDateTime)){this.id3Track||(this.id3Track=this.createTrack(this.media));for(var c=d.programDateTime/1e3-d.start,f=He(),g=function(){var t=o[v],e=s[t],i=We(e.startDate,c),a=n[t],l=(null==a?void 0:a.cues)||{},u=(null==a?void 0:a.durationKnown)||!1,h=Ye,d=e.endDate;if(d)h=We(d,c),u=!0;else if(e.endOnNext&&!u){var g=o.reduce((function(t,r){if(r!==e.id){var i=s[r];if(i.class===e.class&&i.startDate>e.startDate&&(!t||e.startDate<t.startDate))return i}return t}),null);g&&(h=We(g.startDate,c),u=!0)}for(var m,p,y=Object.keys(e.attr),E=0;E<y.length;E++){var T=y[E];if("ID"!==(p=T)&&"CLASS"!==p&&"START-DATE"!==p&&"DURATION"!==p&&"END-DATE"!==p&&"END-ON-NEXT"!==p){var S=l[T];if(S)u&&!a.durationKnown&&(S.endTime=h);else if(f){var L=e.attr[T];P(T)&&(m=L,L=Uint8Array.from(m.replace(/^0x/,"").replace(/([\da-fA-F]{2}) ?/g,"0x$1 ").replace(/ +$/,"").split(" ")).buffer);var A=Ve(f,i,h,{key:T,data:L},Ge);A&&(A.id=t,r.id3Track.addCue(A),l[T]=A)}}}n[t]={cues:l,dateRange:e,durationKnown:u}},v=0;v<o.length;v++)g()}}},t}(),qe=function(){function t(t){var e=this;this.hls=void 0,this.config=void 0,this.media=null,this.levelDetails=null,this.currentTime=0,this.stallCount=0,this._latency=null,this.timeupdateHandler=function(){return e.timeupdate()},this.hls=t,this.config=t.config,this.registerListeners()}var e=t.prototype;return e.destroy=function(){this.unregisterListeners(),this.onMediaDetaching(),this.levelDetails=null,this.hls=this.timeupdateHandler=null},e.registerListeners=function(){this.hls.on(S.MEDIA_ATTACHED,this.onMediaAttached,this),this.hls.on(S.MEDIA_DETACHING,this.onMediaDetaching,this),this.hls.on(S.MANIFEST_LOADING,this.onManifestLoading,this),this.hls.on(S.LEVEL_UPDATED,this.onLevelUpdated,this),this.hls.on(S.ERROR,this.onError,this)},e.unregisterListeners=function(){this.hls.off(S.MEDIA_ATTACHED,this.onMediaAttached,this),this.hls.off(S.MEDIA_DETACHING,this.onMediaDetaching,this),this.hls.off(S.MANIFEST_LOADING,this.onManifestLoading,this),this.hls.off(S.LEVEL_UPDATED,this.onLevelUpdated,this),this.hls.off(S.ERROR,this.onError,this)},e.onMediaAttached=function(t,e){this.media=e.media,this.media.addEventListener("timeupdate",this.timeupdateHandler)},e.onMediaDetaching=function(){this.media&&(this.media.removeEventListener("timeupdate",this.timeupdateHandler),this.media=null)},e.onManifestLoading=function(){this.levelDetails=null,this._latency=null,this.stallCount=0},e.onLevelUpdated=function(t,e){var r=e.details;this.levelDetails=r,r.advanced&&this.timeupdate(),!r.live&&this.media&&this.media.removeEventListener("timeupdate",this.timeupdateHandler)},e.onError=function(t,e){var r;e.details===A.BUFFER_STALLED_ERROR&&(this.stallCount++,null!=(r=this.levelDetails)&&r.live&&w.warn("[playback-rate-controller]: Stall detected, adjusting target latency"))},e.timeupdate=function(){var t=this.media,e=this.levelDetails;if(t&&e){this.currentTime=t.currentTime;var r=this.computeLatency();if(null!==r){this._latency=r;var i=this.config,n=i.lowLatencyMode,a=i.maxLiveSyncPlaybackRate;if(n&&1!==a&&e.live){var s=this.targetLatency;if(null!==s){var o=r-s;if(o<Math.min(this.maxLatency,s+e.targetduration)&&o>.05&&this.forwardBufferLength>1){var l=Math.min(2,Math.max(1,a)),u=Math.round(2/(1+Math.exp(-.75*o-this.edgeStalled))*20)/20;t.playbackRate=Math.min(l,Math.max(1,u))}else 1!==t.playbackRate&&0!==t.playbackRate&&(t.playbackRate=1)}}}}},e.estimateLiveEdge=function(){var t=this.levelDetails;return null===t?null:t.edge+t.age},e.computeLatency=function(){var t=this.estimateLiveEdge();return null===t?null:t-this.currentTime},s(t,[{key:"latency",get:function(){return this._latency||0}},{key:"maxLatency",get:function(){var t=this.config,e=this.levelDetails;return void 0!==t.liveMaxLatencyDuration?t.liveMaxLatencyDuration:e?t.liveMaxLatencyDurationCount*e.targetduration:0}},{key:"targetLatency",get:function(){var t=this.levelDetails;if(null===t)return null;var e=t.holdBack,r=t.partHoldBack,i=t.targetduration,n=this.config,a=n.liveSyncDuration,s=n.liveSyncDurationCount,o=n.lowLatencyMode,l=this.hls.userConfig,u=o&&r||e;(l.liveSyncDuration||l.liveSyncDurationCount||0===u)&&(u=void 0!==a?a:s*i);var h=i;return u+Math.min(1*this.stallCount,h)}},{key:"liveSyncPosition",get:function(){var t=this.estimateLiveEdge(),e=this.targetLatency,r=this.levelDetails;if(null===t||null===e||null===r)return null;var i=r.edge,n=t-e-this.edgeStalled,a=i-r.totalduration,s=i-(this.config.lowLatencyMode&&r.partTarget||r.targetduration);return Math.min(Math.max(a,n),s)}},{key:"drift",get:function(){var t=this.levelDetails;return null===t?1:t.drift}},{key:"edgeStalled",get:function(){var t=this.levelDetails;if(null===t)return 0;var e=3*(this.config.lowLatencyMode&&t.partTarget||t.targetduration);return Math.max(t.age-e,0)}},{key:"forwardBufferLength",get:function(){var t=this.media,e=this.levelDetails;if(!t||!e)return 0;var r=t.buffered.length;return(r?t.buffered.end(r-1):e.edge)-this.currentTime}}]),t}(),Xe=["NONE","TYPE-0","TYPE-1",null],ze=["SDR","PQ","HLG"],Qe="",Je="YES",$e="v2",Ze=function(){function t(t,e,r){this.msn=void 0,this.part=void 0,this.skip=void 0,this.msn=t,this.part=e,this.skip=r}return t.prototype.addDirectives=function(t){var e=new self.URL(t);return void 0!==this.msn&&e.searchParams.set("_HLS_msn",this.msn.toString()),void 0!==this.part&&e.searchParams.set("_HLS_part",this.part.toString()),this.skip&&e.searchParams.set("_HLS_skip",this.skip),e.href},t}(),tr=function(){function t(t){this._attrs=void 0,this.audioCodec=void 0,this.bitrate=void 0,this.codecSet=void 0,this.url=void 0,this.frameRate=void 0,this.height=void 0,this.id=void 0,this.name=void 0,this.videoCodec=void 0,this.width=void 0,this.details=void 0,this.fragmentError=0,this.loadError=0,this.loaded=void 0,this.realBitrate=0,this.supportedPromise=void 0,this.supportedResult=void 0,this._avgBitrate=0,this._audioGroups=void 0,this._subtitleGroups=void 0,this._urlId=0,this.url=[t.url],this._attrs=[t.attrs],this.bitrate=t.bitrate,t.details&&(this.details=t.details),this.id=t.id||0,this.name=t.name,this.width=t.width||0,this.height=t.height||0,this.frameRate=t.attrs.optionalFloat("FRAME-RATE",0),this._avgBitrate=t.attrs.decimalInteger("AVERAGE-BANDWIDTH"),this.audioCodec=t.audioCodec,this.videoCodec=t.videoCodec,this.codecSet=[t.videoCodec,t.audioCodec].filter((function(t){return!!t})).map((function(t){return t.substring(0,4)})).join(","),this.addGroupId("audio",t.attrs.AUDIO),this.addGroupId("text",t.attrs.SUBTITLES)}var e=t.prototype;return e.hasAudioGroup=function(t){return er(this._audioGroups,t)},e.hasSubtitleGroup=function(t){return er(this._subtitleGroups,t)},e.addGroupId=function(t,e){if(e)if("audio"===t){var r=this._audioGroups;r||(r=this._audioGroups=[]),-1===r.indexOf(e)&&r.push(e)}else if("text"===t){var i=this._subtitleGroups;i||(i=this._subtitleGroups=[]),-1===i.indexOf(e)&&i.push(e)}},e.addFallback=function(){},s(t,[{key:"maxBitrate",get:function(){return Math.max(this.realBitrate,this.bitrate)}},{key:"averageBitrate",get:function(){return this._avgBitrate||this.realBitrate||this.bitrate}},{key:"attrs",get:function(){return this._attrs[0]}},{key:"codecs",get:function(){return this.attrs.CODECS||""}},{key:"pathwayId",get:function(){return this.attrs["PATHWAY-ID"]||"."}},{key:"videoRange",get:function(){return this.attrs["VIDEO-RANGE"]||"SDR"}},{key:"score",get:function(){return this.attrs.optionalFloat("SCORE",0)}},{key:"uri",get:function(){return this.url[0]||""}},{key:"audioGroups",get:function(){return this._audioGroups}},{key:"subtitleGroups",get:function(){return this._subtitleGroups}},{key:"urlId",get:function(){return 0},set:function(t){}},{key:"audioGroupIds",get:function(){return this.audioGroups?[this.audioGroupId]:void 0}},{key:"textGroupIds",get:function(){return this.subtitleGroups?[this.textGroupId]:void 0}},{key:"audioGroupId",get:function(){var t;return null==(t=this.audioGroups)?void 0:t[0]}},{key:"textGroupId",get:function(){var t;return null==(t=this.subtitleGroups)?void 0:t[0]}}]),t}();function er(t,e){return!(!e||!t)&&-1!==t.indexOf(e)}function rr(t,e){var r=e.startPTS;if(y(r)){var i,n=0;e.sn>t.sn?(n=r-t.start,i=t):(n=t.start-r,i=e),i.duration!==n&&(i.duration=n)}else e.sn>t.sn?t.cc===e.cc&&t.minEndPTS?e.start=t.start+(t.minEndPTS-t.start):e.start=t.start+t.duration:e.start=Math.max(t.start-e.duration,0)}function ir(t,e,r,i,n,a){i-r<=0&&(w.warn("Fragment should have a positive duration",e),i=r+e.duration,a=n+e.duration);var s=r,o=i,l=e.startPTS,u=e.endPTS;if(y(l)){var h=Math.abs(l-r);y(e.deltaPTS)?e.deltaPTS=Math.max(h,e.deltaPTS):e.deltaPTS=h,s=Math.max(r,l),r=Math.min(r,l),n=Math.min(n,e.startDTS),o=Math.min(i,u),i=Math.max(i,u),a=Math.max(a,e.endDTS)}var d=r-e.start;0!==e.start&&(e.start=r),e.duration=i-e.start,e.startPTS=r,e.maxStartPTS=s,e.startDTS=n,e.endPTS=i,e.minEndPTS=o,e.endDTS=a;var c,f=e.sn;if(!t||f<t.startSN||f>t.endSN)return 0;var g=f-t.startSN,v=t.fragments;for(v[g]=e,c=g;c>0;c--)rr(v[c],v[c-1]);for(c=g;c<v.length-1;c++)rr(v[c],v[c+1]);return t.fragmentHint&&rr(v[v.length-1],t.fragmentHint),t.PTSKnown=t.alignedSliding=!0,d}function nr(t,e){for(var r=null,i=t.fragments,n=i.length-1;n>=0;n--){var a=i[n].initSegment;if(a){r=a;break}}t.fragmentHint&&delete t.fragmentHint.endPTS;var s,l,u,h,d,c=0;if(function(t,e,r){for(var i=e.skippedSegments,n=Math.max(t.startSN,e.startSN)-e.startSN,a=(t.fragmentHint?1:0)+(i?e.endSN:Math.min(t.endSN,e.endSN))-e.startSN,s=e.startSN-t.startSN,o=e.fragmentHint?e.fragments.concat(e.fragmentHint):e.fragments,l=t.fragmentHint?t.fragments.concat(t.fragmentHint):t.fragments,u=n;u<=a;u++){var h=l[s+u],d=o[u];i&&!d&&u<i&&(d=e.fragments[u]=h),h&&d&&r(h,d)}}(t,e,(function(t,i){t.relurl&&(c=t.cc-i.cc),y(t.startPTS)&&y(t.endPTS)&&(i.start=i.startPTS=t.startPTS,i.startDTS=t.startDTS,i.maxStartPTS=t.maxStartPTS,i.endPTS=t.endPTS,i.endDTS=t.endDTS,i.minEndPTS=t.minEndPTS,i.duration=t.endPTS-t.startPTS,i.duration&&(s=i),e.PTSKnown=e.alignedSliding=!0),i.elementaryStreams=t.elementaryStreams,i.loader=t.loader,i.stats=t.stats,t.initSegment&&(i.initSegment=t.initSegment,r=t.initSegment)})),r&&(e.fragmentHint?e.fragments.concat(e.fragmentHint):e.fragments).forEach((function(t){var e;!t||t.initSegment&&t.initSegment.relurl!==(null==(e=r)?void 0:e.relurl)||(t.initSegment=r)})),e.skippedSegments)if(e.deltaUpdateFailed=e.fragments.some((function(t){return!t})),e.deltaUpdateFailed){w.warn("[level-helper] Previous playlist missing segments skipped in delta playlist");for(var f=e.skippedSegments;f--;)e.fragments.shift();e.startSN=e.fragments[0].sn,e.startCC=e.fragments[0].cc}else e.canSkipDateRanges&&(e.dateRanges=(l=t.dateRanges,u=e.dateRanges,h=e.recentlyRemovedDateranges,d=o({},l),h&&h.forEach((function(t){delete d[t]})),Object.keys(u).forEach((function(t){var e=new F(u[t].attr,d[t]);e.isValid?d[t]=e:w.warn('Ignoring invalid Playlist Delta Update DATERANGE tag: "'+JSON.stringify(u[t].attr)+'"')})),d));var g=e.fragments;if(c){w.warn("discontinuity sliding from playlist, take drift into account");for(var v=0;v<g.length;v++)g[v].cc+=c}e.skippedSegments&&(e.startCC=e.fragments[0].cc),function(t,e,r){if(t&&e)for(var i=0,n=0,a=t.length;n<=a;n++){var s=t[n],o=e[n+i];s&&o&&s.index===o.index&&s.fragment.sn===o.fragment.sn?r(s,o):i--}}(t.partList,e.partList,(function(t,e){e.elementaryStreams=t.elementaryStreams,e.stats=t.stats})),s?ir(e,s,s.startPTS,s.endPTS,s.startDTS,s.endDTS):ar(t,e),g.length&&(e.totalduration=e.edge-g[0].start),e.driftStartTime=t.driftStartTime,e.driftStart=t.driftStart;var m=e.advancedDateTime;if(e.advanced&&m){var p=e.edge;e.driftStart||(e.driftStartTime=m,e.driftStart=p),e.driftEndTime=m,e.driftEnd=p}else e.driftEndTime=t.driftEndTime,e.driftEnd=t.driftEnd,e.advancedDateTime=t.advancedDateTime}function ar(t,e){var r=e.startSN+e.skippedSegments-t.startSN,i=t.fragments;r<0||r>=i.length||sr(e,i[r].start)}function sr(t,e){if(e){for(var r=t.fragments,i=t.skippedSegments;i<r.length;i++)r[i].start+=e;t.fragmentHint&&(t.fragmentHint.start+=e)}}function or(t,e,r){var i;return null!=t&&t.details?lr(null==(i=t.details)?void 0:i.partList,e,r):null}function lr(t,e,r){if(t)for(var i=t.length;i--;){var n=t[i];if(n.index===r&&n.fragment.sn===e)return n}return null}function ur(t){t.forEach((function(t,e){var r=t.details;null!=r&&r.fragments&&r.fragments.forEach((function(t){t.level=e}))}))}function hr(t){switch(t.details){case A.FRAG_LOAD_TIMEOUT:case A.KEY_LOAD_TIMEOUT:case A.LEVEL_LOAD_TIMEOUT:case A.MANIFEST_LOAD_TIMEOUT:return!0}return!1}function dr(t,e){var r=hr(e);return t.default[(r?"timeout":"error")+"Retry"]}function cr(t,e){var r="linear"===t.backoff?1:Math.pow(2,e);return Math.min(r*t.retryDelayMs,t.maxRetryDelayMs)}function fr(t){return i(i({},t),{errorRetry:null,timeoutRetry:null})}function gr(t,e,r,i){if(!t)return!1;var n=null==i?void 0:i.code,a=e<t.maxNumRetry&&(function(t){return 0===t&&!1===navigator.onLine||!!t&&(t<400||t>499)}(n)||!!r);return t.shouldRetry?t.shouldRetry(t,e,r,i,a):a}var vr=function(t,e){for(var r=0,i=t.length-1,n=null,a=null;r<=i;){var s=e(a=t[n=(r+i)/2|0]);if(s>0)r=n+1;else{if(!(s<0))return a;i=n-1}}return null};function mr(t,e,r,i){void 0===r&&(r=0),void 0===i&&(i=0);var n=null;if(t){n=e[t.sn-e[0].sn+1]||null;var a=t.endDTS-r;a>0&&a<15e-7&&(r+=15e-7)}else 0===r&&0===e[0].start&&(n=e[0]);if(n&&(!t||t.level===n.level)&&0===pr(r,i,n))return n;var s=vr(e,pr.bind(null,r,i));return!s||s===t&&n?n:s}function pr(t,e,r){if(void 0===t&&(t=0),void 0===e&&(e=0),r.start<=t&&r.start+r.duration>t)return 0;var i=Math.min(e,r.duration+(r.deltaPTS?r.deltaPTS:0));return r.start+r.duration-i<=t?1:r.start-i>t&&r.start?-1:0}function yr(t,e,r){var i=1e3*Math.min(e,r.duration+(r.deltaPTS?r.deltaPTS:0));return(r.endProgramDateTime||0)-i>t}var Er=0,Tr=2,Sr=3,Lr=5,Ar=0,Rr=1,kr=2,br=function(){function t(t){this.hls=void 0,this.playlistError=0,this.penalizedRenditions={},this.log=void 0,this.warn=void 0,this.error=void 0,this.hls=t,this.log=w.log.bind(w,"[info]:"),this.warn=w.warn.bind(w,"[warning]:"),this.error=w.error.bind(w,"[error]:"),this.registerListeners()}var e=t.prototype;return e.registerListeners=function(){var t=this.hls;t.on(S.ERROR,this.onError,this),t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.LEVEL_UPDATED,this.onLevelUpdated,this)},e.unregisterListeners=function(){var t=this.hls;t&&(t.off(S.ERROR,this.onError,this),t.off(S.ERROR,this.onErrorOut,this),t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.LEVEL_UPDATED,this.onLevelUpdated,this))},e.destroy=function(){this.unregisterListeners(),this.hls=null,this.penalizedRenditions={}},e.startLoad=function(t){},e.stopLoad=function(){this.playlistError=0},e.getVariantLevelIndex=function(t){return(null==t?void 0:t.type)===Ie?t.level:this.hls.loadLevel},e.onManifestLoading=function(){this.playlistError=0,this.penalizedRenditions={}},e.onLevelUpdated=function(){this.playlistError=0},e.onError=function(t,e){var r,i;if(!e.fatal){var n=this.hls,a=e.context;switch(e.details){case A.FRAG_LOAD_ERROR:case A.FRAG_LOAD_TIMEOUT:case A.KEY_LOAD_ERROR:case A.KEY_LOAD_TIMEOUT:return void(e.errorAction=this.getFragRetryOrSwitchAction(e));case A.FRAG_PARSING_ERROR:if(null!=(r=e.frag)&&r.gap)return void(e.errorAction={action:Er,flags:Ar});case A.FRAG_GAP:case A.FRAG_DECRYPT_ERROR:return e.errorAction=this.getFragRetryOrSwitchAction(e),void(e.errorAction.action=Tr);case A.LEVEL_EMPTY_ERROR:case A.LEVEL_PARSING_ERROR:var s,o,l=e.parent===Ie?e.level:n.loadLevel;return void(e.details===A.LEVEL_EMPTY_ERROR&&null!=(s=e.context)&&null!=(o=s.levelDetails)&&o.live?e.errorAction=this.getPlaylistRetryOrSwitchAction(e,l):(e.levelRetry=!1,e.errorAction=this.getLevelSwitchAction(e,l)));case A.LEVEL_LOAD_ERROR:case A.LEVEL_LOAD_TIMEOUT:return void("number"==typeof(null==a?void 0:a.level)&&(e.errorAction=this.getPlaylistRetryOrSwitchAction(e,a.level)));case A.AUDIO_TRACK_LOAD_ERROR:case A.AUDIO_TRACK_LOAD_TIMEOUT:case A.SUBTITLE_LOAD_ERROR:case A.SUBTITLE_TRACK_LOAD_TIMEOUT:if(a){var u=n.levels[n.loadLevel];if(u&&(a.type===be&&u.hasAudioGroup(a.groupId)||a.type===De&&u.hasSubtitleGroup(a.groupId)))return e.errorAction=this.getPlaylistRetryOrSwitchAction(e,n.loadLevel),e.errorAction.action=Tr,void(e.errorAction.flags=Rr)}return;case A.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED:var h=n.levels[n.loadLevel],d=null==h?void 0:h.attrs["HDCP-LEVEL"];return void(d?e.errorAction={action:Tr,flags:kr,hdcpLevel:d}:this.keySystemError(e));case A.BUFFER_ADD_CODEC_ERROR:case A.REMUX_ALLOC_ERROR:case A.BUFFER_APPEND_ERROR:return void(e.errorAction=this.getLevelSwitchAction(e,null!=(i=e.level)?i:n.loadLevel));case A.INTERNAL_EXCEPTION:case A.BUFFER_APPENDING_ERROR:case A.BUFFER_FULL_ERROR:case A.LEVEL_SWITCH_ERROR:case A.BUFFER_STALLED_ERROR:case A.BUFFER_SEEK_OVER_HOLE:case A.BUFFER_NUDGE_ON_STALL:return void(e.errorAction={action:Er,flags:Ar})}e.type===L.KEY_SYSTEM_ERROR&&this.keySystemError(e)}},e.keySystemError=function(t){var e=this.getVariantLevelIndex(t.frag);t.levelRetry=!1,t.errorAction=this.getLevelSwitchAction(t,e)},e.getPlaylistRetryOrSwitchAction=function(t,e){var r=dr(this.hls.config.playlistLoadPolicy,t),i=this.playlistError++;if(gr(r,i,hr(t),t.response))return{action:Lr,flags:Ar,retryConfig:r,retryCount:i};var n=this.getLevelSwitchAction(t,e);return r&&(n.retryConfig=r,n.retryCount=i),n},e.getFragRetryOrSwitchAction=function(t){var e=this.hls,r=this.getVariantLevelIndex(t.frag),i=e.levels[r],n=e.config,a=n.fragLoadPolicy,s=n.keyLoadPolicy,o=dr(t.details.startsWith("key")?s:a,t),l=e.levels.reduce((function(t,e){return t+e.fragmentError}),0);if(i&&(t.details!==A.FRAG_GAP&&i.fragmentError++,gr(o,l,hr(t),t.response)))return{action:Lr,flags:Ar,retryConfig:o,retryCount:l};var u=this.getLevelSwitchAction(t,r);return o&&(u.retryConfig=o,u.retryCount=l),u},e.getLevelSwitchAction=function(t,e){var r=this.hls;null==e&&(e=r.loadLevel);var i=this.hls.levels[e];if(i){var n,a,s=t.details;i.loadError++,s===A.BUFFER_APPEND_ERROR&&i.fragmentError++;var o=-1,l=r.levels,u=r.loadLevel,h=r.minAutoLevel,d=r.maxAutoLevel;r.autoLevelEnabled||(r.loadLevel=-1);for(var c,f=null==(n=t.frag)?void 0:n.type,g=(f===we&&s===A.FRAG_PARSING_ERROR||"audio"===t.sourceBufferName&&(s===A.BUFFER_ADD_CODEC_ERROR||s===A.BUFFER_APPEND_ERROR))&&l.some((function(t){var e=t.audioCodec;return i.audioCodec!==e})),v="video"===t.sourceBufferName&&(s===A.BUFFER_ADD_CODEC_ERROR||s===A.BUFFER_APPEND_ERROR)&&l.some((function(t){var e=t.codecSet,r=t.audioCodec;return i.codecSet!==e&&i.audioCodec===r})),m=null!=(a=t.context)?a:{},p=m.type,y=m.groupId,E=function(){var e=(T+u)%l.length;if(e!==u&&e>=h&&e<=d&&0===l[e].loadError){var r,n,a=l[e];if(s===A.FRAG_GAP&&t.frag){var c=l[e].details;if(c){var m=mr(t.frag,c.fragments,t.frag.start);if(null!=m&&m.gap)return 0}}else{if(p===be&&a.hasAudioGroup(y)||p===De&&a.hasSubtitleGroup(y))return 0;if(f===we&&null!=(r=i.audioGroups)&&r.some((function(t){return a.hasAudioGroup(t)}))||f===Ce&&null!=(n=i.subtitleGroups)&&n.some((function(t){return a.hasSubtitleGroup(t)}))||g&&i.audioCodec===a.audioCodec||!g&&i.audioCodec!==a.audioCodec||v&&i.codecSet===a.codecSet)return 0}return o=e,1}},T=l.length;T--&&(0===(c=E())||1!==c););if(o>-1&&r.loadLevel!==o)return t.levelRetry=!0,this.playlistError=0,{action:Tr,flags:Ar,nextAutoLevel:o}}return{action:Tr,flags:Rr}},e.onErrorOut=function(t,e){var r;switch(null==(r=e.errorAction)?void 0:r.action){case Er:break;case Tr:this.sendAlternateToPenaltyBox(e),e.errorAction.resolved||e.details===A.FRAG_GAP?/MediaSource readyState: ended/.test(e.error.message)&&(this.warn('MediaSource ended after "'+e.sourceBufferName+'" sourceBuffer append error. Attempting to recover from media error.'),this.hls.recoverMediaError()):e.fatal=!0}e.fatal&&this.hls.stopLoad()},e.sendAlternateToPenaltyBox=function(t){var e=this.hls,r=t.errorAction;if(r){var i=r.flags,n=r.hdcpLevel,a=r.nextAutoLevel;switch(i){case Ar:this.switchLevel(t,a);break;case kr:n&&(e.maxHdcpLevel=Xe[Xe.indexOf(n)-1],r.resolved=!0),this.warn('Restricting playback to HDCP-LEVEL of "'+e.maxHdcpLevel+'" or lower')}r.resolved||this.switchLevel(t,a)}},e.switchLevel=function(t,e){void 0!==e&&t.errorAction&&(this.warn("switching to level "+e+" after "+t.details),this.hls.nextAutoLevel=e,t.errorAction.resolved=!0,this.hls.nextLoadLevel=this.hls.nextAutoLevel)},t}(),Dr=function(){function t(t,e){this.hls=void 0,this.timer=-1,this.requestScheduled=-1,this.canLoad=!1,this.log=void 0,this.warn=void 0,this.log=w.log.bind(w,e+":"),this.warn=w.warn.bind(w,e+":"),this.hls=t}var e=t.prototype;return e.destroy=function(){this.clearTimer(),this.hls=this.log=this.warn=null},e.clearTimer=function(){-1!==this.timer&&(self.clearTimeout(this.timer),this.timer=-1)},e.startLoad=function(){this.canLoad=!0,this.requestScheduled=-1,this.loadPlaylist()},e.stopLoad=function(){this.canLoad=!1,this.clearTimer()},e.switchParams=function(t,e){var r=null==e?void 0:e.renditionReports;if(r){for(var i=-1,n=0;n<r.length;n++){var a=r[n],s=void 0;try{s=new self.URL(a.URI,e.url).href}catch(t){w.warn("Could not construct new URL for Rendition Report: "+t),s=a.URI||""}if(s===t){i=n;break}s===t.substring(0,s.length)&&(i=n)}if(-1!==i){var o=r[i],l=parseInt(o["LAST-MSN"])||(null==e?void 0:e.lastPartSn),u=parseInt(o["LAST-PART"])||(null==e?void 0:e.lastPartIndex);if(this.hls.config.lowLatencyMode){var h=Math.min(e.age-e.partTarget,e.targetduration);u>=0&&h>e.partTarget&&(u+=1)}return new Ze(l,u>=0?u:void 0,Qe)}}},e.loadPlaylist=function(t){-1===this.requestScheduled&&(this.requestScheduled=self.performance.now())},e.shouldLoadPlaylist=function(t){return this.canLoad&&!!t&&!!t.url&&(!t.details||t.details.live)},e.shouldReloadPlaylist=function(t){return-1===this.timer&&-1===this.requestScheduled&&this.shouldLoadPlaylist(t)},e.playlistLoaded=function(t,e,r){var i=this,n=e.details,a=e.stats,s=self.performance.now(),o=a.loading.first?Math.max(0,s-a.loading.first):0;if(n.advancedDateTime=Date.now()-o,n.live||null!=r&&r.live){if(n.reloaded(r),r&&this.log("live playlist "+t+" "+(n.advanced?"REFRESHED "+n.lastPartSn+"-"+n.lastPartIndex:n.updated?"UPDATED":"MISSED")),r&&n.fragments.length>0&&nr(r,n),!this.canLoad||!n.live)return;var l,u=void 0,h=void 0;if(n.canBlockReload&&n.endSN&&n.advanced){var d=this.hls.config.lowLatencyMode,c=n.lastPartSn,f=n.endSN,g=n.lastPartIndex,v=c===f;-1!==g?(u=v?f+1:c,h=v?d?0:g:g+1):u=f+1;var m=n.age,p=m+n.ageHeader,y=Math.min(p-n.partTarget,1.5*n.targetduration);if(y>0){if(r&&y>r.tuneInGoal)this.warn("CDN Tune-in goal increased from: "+r.tuneInGoal+" to: "+y+" with playlist age: "+n.age),y=0;else{var E=Math.floor(y/n.targetduration);u+=E,void 0!==h&&(h+=Math.round(y%n.targetduration/n.partTarget)),this.log("CDN Tune-in age: "+n.ageHeader+"s last advanced "+m.toFixed(2)+"s goal: "+y+" skip sn "+E+" to part "+h)}n.tuneInGoal=y}if(l=this.getDeliveryDirectives(n,e.deliveryDirectives,u,h),d||!v)return void this.loadPlaylist(l)}else(n.canBlockReload||n.canSkipUntil)&&(l=this.getDeliveryDirectives(n,e.deliveryDirectives,u,h));var T=this.hls.mainForwardBufferInfo,S=T?T.end-T.len:0,L=function(t,e){void 0===e&&(e=1/0);var r=1e3*t.targetduration;if(t.updated){var i=t.fragments;if(i.length&&4*r>e){var n=1e3*i[i.length-1].duration;n<r&&(r=n)}}else r/=2;return Math.round(r)}(n,1e3*(n.edge-S));n.updated&&s>this.requestScheduled+L&&(this.requestScheduled=a.loading.start),void 0!==u&&n.canBlockReload?this.requestScheduled=a.loading.first+L-(1e3*n.partTarget||1e3):-1===this.requestScheduled||this.requestScheduled+L<s?this.requestScheduled=s:this.requestScheduled-s<=0&&(this.requestScheduled+=L);var A=this.requestScheduled-s;A=Math.max(0,A),this.log("reload live playlist "+t+" in "+Math.round(A)+" ms"),this.timer=self.setTimeout((function(){return i.loadPlaylist(l)}),A)}else this.clearTimer()},e.getDeliveryDirectives=function(t,e,r,i){var n=function(t,e){var r=t.canSkipUntil,i=t.canSkipDateRanges,n=t.endSN;return r&&(void 0!==e?e-n:0)<r?i?$e:Je:Qe}(t,r);return null!=e&&e.skip&&t.deltaUpdateFailed&&(r=e.msn,i=e.part,n=Qe),new Ze(r,i,n)},e.checkRetry=function(t){var e=this,r=t.details,i=hr(t),n=t.errorAction,a=n||{},s=a.action,o=a.retryCount,l=void 0===o?0:o,u=a.retryConfig,h=!!n&&!!u&&(s===Lr||!n.resolved&&s===Tr);if(h){var d;if(this.requestScheduled=-1,l>=u.maxNumRetry)return!1;if(i&&null!=(d=t.context)&&d.deliveryDirectives)this.warn("Retrying playlist loading "+(l+1)+"/"+u.maxNumRetry+' after "'+r+'" without delivery-directives'),this.loadPlaylist();else{var c=cr(u,l);this.timer=self.setTimeout((function(){return e.loadPlaylist()}),c),this.warn("Retrying playlist loading "+(l+1)+"/"+u.maxNumRetry+' after "'+r+'" in '+c+"ms")}t.levelRetry=!0,n.resolved=!0}return h},t}(),Ir=function(){function t(t,e,r){void 0===e&&(e=0),void 0===r&&(r=0),this.halfLife=void 0,this.alpha_=void 0,this.estimate_=void 0,this.totalWeight_=void 0,this.halfLife=t,this.alpha_=t?Math.exp(Math.log(.5)/t):0,this.estimate_=e,this.totalWeight_=r}var e=t.prototype;return e.sample=function(t,e){var r=Math.pow(this.alpha_,t);this.estimate_=e*(1-r)+r*this.estimate_,this.totalWeight_+=t},e.getTotalWeight=function(){return this.totalWeight_},e.getEstimate=function(){if(this.alpha_){var t=1-Math.pow(this.alpha_,this.totalWeight_);if(t)return this.estimate_/t}return this.estimate_},t}(),wr=function(){function t(t,e,r,i){void 0===i&&(i=100),this.defaultEstimate_=void 0,this.minWeight_=void 0,this.minDelayMs_=void 0,this.slow_=void 0,this.fast_=void 0,this.defaultTTFB_=void 0,this.ttfb_=void 0,this.defaultEstimate_=r,this.minWeight_=.001,this.minDelayMs_=50,this.slow_=new Ir(t),this.fast_=new Ir(e),this.defaultTTFB_=i,this.ttfb_=new Ir(t)}var e=t.prototype;return e.update=function(t,e){var r=this.slow_,i=this.fast_,n=this.ttfb_;r.halfLife!==t&&(this.slow_=new Ir(t,r.getEstimate(),r.getTotalWeight())),i.halfLife!==e&&(this.fast_=new Ir(e,i.getEstimate(),i.getTotalWeight())),n.halfLife!==t&&(this.ttfb_=new Ir(t,n.getEstimate(),n.getTotalWeight()))},e.sample=function(t,e){var r=(t=Math.max(t,this.minDelayMs_))/1e3,i=8*e/r;this.fast_.sample(r,i),this.slow_.sample(r,i)},e.sampleTTFB=function(t){var e=t/1e3,r=Math.sqrt(2)*Math.exp(-Math.pow(e,2)/2);this.ttfb_.sample(r,Math.max(t,5))},e.canEstimate=function(){return this.fast_.getTotalWeight()>=this.minWeight_},e.getEstimate=function(){return this.canEstimate()?Math.min(this.fast_.getEstimate(),this.slow_.getEstimate()):this.defaultEstimate_},e.getEstimateTTFB=function(){return this.ttfb_.getTotalWeight()>=this.minWeight_?this.ttfb_.getEstimate():this.defaultTTFB_},e.destroy=function(){},t}(),Cr={supported:!0,configurations:[],decodingInfoResults:[{supported:!0,powerEfficient:!0,smooth:!0}]},_r={};function xr(t,e,r){var n=t.videoCodec,a=t.audioCodec;if(!n||!a||!r)return Promise.resolve(Cr);var s={width:t.width,height:t.height,bitrate:Math.ceil(Math.max(.9*t.bitrate,t.averageBitrate)),framerate:t.frameRate||30},o=t.videoRange;"SDR"!==o&&(s.transferFunction=o.toLowerCase());var l=n.split(",").map((function(t){return{type:"media-source",video:i(i({},s),{},{contentType:ne(t,"video")})}}));return a&&t.audioGroups&&t.audioGroups.forEach((function(t){var r;t&&(null==(r=e.groups[t])||r.tracks.forEach((function(e){if(e.groupId===t){var r=e.channels||"",i=parseFloat(r);y(i)&&i>2&&l.push.apply(l,a.split(",").map((function(t){return{type:"media-source",audio:{contentType:ne(t,"audio"),channels:""+i}}})))}})))})),Promise.all(l.map((function(t){var e=function(t){var e=t.audio,r=t.video,i=r||e;if(i){var n=i.contentType.split('"')[1];if(r)return"r"+r.height+"x"+r.width+"f"+Math.ceil(r.framerate)+(r.transferFunction||"sd")+"_"+n+"_"+Math.ceil(r.bitrate/1e5);if(e)return"c"+e.channels+(e.spatialRendering?"s":"n")+"_"+n}return""}(t);return _r[e]||(_r[e]=r.decodingInfo(t))}))).then((function(t){return{supported:!t.some((function(t){return!t.supported})),configurations:l,decodingInfoResults:t}})).catch((function(t){return{supported:!1,configurations:l,decodingInfoResults:[],error:t}}))}function Pr(t,e){var r=!1,i=[];return t&&(r="SDR"!==t,i=[t]),e&&(i=e.allowedVideoRanges||ze.slice(0),i=(r=void 0!==e.preferHDR?e.preferHDR:function(){if("function"==typeof matchMedia){var t=matchMedia("(dynamic-range: high)"),e=matchMedia("bad query");if(t.media!==e.media)return!0===t.matches}return!1}())?i.filter((function(t){return"SDR"!==t})):["SDR"]),{preferHDR:r,allowedVideoRanges:i}}function Fr(t,e){w.log('[abr] start candidates with "'+t+'" ignored because '+e)}function Mr(t,e,r){if("attrs"in t){var i=e.indexOf(t);if(-1!==i)return i}for(var n=0;n<e.length;n++)if(Or(t,e[n],r))return n;return-1}function Or(t,e,r){var i=t.groupId,n=t.name,a=t.lang,s=t.assocLang,o=t.characteristics,l=t.default,u=t.forced;return(void 0===i||e.groupId===i)&&(void 0===n||e.name===n)&&(void 0===a||e.lang===a)&&(void 0===a||e.assocLang===s)&&(void 0===l||e.default===l)&&(void 0===u||e.forced===u)&&(void 0===o||function(t,e){void 0===e&&(e="");var r=t.split(","),i=e.split(",");return r.length===i.length&&!r.some((function(t){return-1===i.indexOf(t)}))}(o,e.characteristics))&&(void 0===r||r(t,e))}function Nr(t,e){var r=t.audioCodec,i=t.channels;return!(void 0!==r&&(e.audioCodec||"").substring(0,4)!==r.substring(0,4)||void 0!==i&&i!==(e.channels||"2"))}function Ur(t,e,r){for(var i=e;i;i--)if(r(t[i]))return i;for(var n=e+1;n<t.length;n++)if(r(t[n]))return n;return-1}var Br=function(){function t(t){var e=this;this.hls=void 0,this.lastLevelLoadSec=0,this.lastLoadedFragLevel=-1,this.firstSelection=-1,this._nextAutoLevel=-1,this.nextAutoLevelKey="",this.audioTracksByGroup=null,this.codecTiers=null,this.timer=-1,this.fragCurrent=null,this.partCurrent=null,this.bitrateTestDelay=0,this.bwEstimator=void 0,this._abandonRulesCheck=function(){var t=e.fragCurrent,r=e.partCurrent,i=e.hls,n=i.autoLevelEnabled,a=i.media;if(t&&a){var s=performance.now(),o=r?r.stats:t.stats,l=r?r.duration:t.duration,u=s-o.loading.start,h=i.minAutoLevel;if(o.aborted||o.loaded&&o.loaded===o.total||t.level<=h)return e.clearTimer(),void(e._nextAutoLevel=-1);if(n&&!a.paused&&a.playbackRate&&a.readyState){var d=i.mainForwardBufferInfo;if(null!==d){var c=e.bwEstimator.getEstimateTTFB(),f=Math.abs(a.playbackRate);if(!(u<=Math.max(c,l/(2*f)*1e3))){var g=d.len/f,v=o.loading.first?o.loading.first-o.loading.start:-1,m=o.loaded&&v>-1,p=e.getBwEstimate(),E=i.levels,T=E[t.level],L=o.total||Math.max(o.loaded,Math.round(l*T.maxBitrate/8)),A=m?u-v:u;A<1&&m&&(A=Math.min(u,8*o.loaded/p));var R=m?1e3*o.loaded/A:0,k=R?(L-o.loaded)/R:8*L/p+c/1e3;if(!(k<=g)){var b,D=R?8*R:p,I=Number.POSITIVE_INFINITY;for(b=t.level-1;b>h;b--){var C=E[b].maxBitrate;if((I=e.getTimeToLoadFrag(c/1e3,D,l*C,!E[b].details))<g)break}if(!(I>=k||I>10*l)){i.nextLoadLevel=i.nextAutoLevel=b,m?e.bwEstimator.sample(u-Math.min(c,v),o.loaded):e.bwEstimator.sampleTTFB(u);var _=E[b].bitrate;e.getBwEstimate()*e.hls.config.abrBandWidthUpFactor>_&&e.resetEstimator(_),e.clearTimer(),w.warn("[abr] Fragment "+t.sn+(r?" part "+r.index:"")+" of level "+t.level+" is loading too slowly;\n Time to underbuffer: "+g.toFixed(3)+" s\n Estimated load time for current fragment: "+k.toFixed(3)+" s\n Estimated load time for down switch fragment: "+I.toFixed(3)+" s\n TTFB estimate: "+(0|v)+" ms\n Current BW estimate: "+(y(p)?0|p:"Unknown")+" bps\n New BW estimate: "+(0|e.getBwEstimate())+" bps\n Switching to level "+b+" @ "+(0|_)+" bps"),i.trigger(S.FRAG_LOAD_EMERGENCY_ABORTED,{frag:t,part:r,stats:o})}}}}}}},this.hls=t,this.bwEstimator=this.initEstimator(),this.registerListeners()}var e=t.prototype;return e.resetEstimator=function(t){t&&(w.log("setting initial bwe to "+t),this.hls.config.abrEwmaDefaultEstimate=t),this.firstSelection=-1,this.bwEstimator=this.initEstimator()},e.initEstimator=function(){var t=this.hls.config;return new wr(t.abrEwmaSlowVoD,t.abrEwmaFastVoD,t.abrEwmaDefaultEstimate)},e.registerListeners=function(){var t=this.hls;t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.FRAG_LOADING,this.onFragLoading,this),t.on(S.FRAG_LOADED,this.onFragLoaded,this),t.on(S.FRAG_BUFFERED,this.onFragBuffered,this),t.on(S.LEVEL_SWITCHING,this.onLevelSwitching,this),t.on(S.LEVEL_LOADED,this.onLevelLoaded,this),t.on(S.LEVELS_UPDATED,this.onLevelsUpdated,this),t.on(S.MAX_AUTO_LEVEL_UPDATED,this.onMaxAutoLevelUpdated,this),t.on(S.ERROR,this.onError,this)},e.unregisterListeners=function(){var t=this.hls;t&&(t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.FRAG_LOADING,this.onFragLoading,this),t.off(S.FRAG_LOADED,this.onFragLoaded,this),t.off(S.FRAG_BUFFERED,this.onFragBuffered,this),t.off(S.LEVEL_SWITCHING,this.onLevelSwitching,this),t.off(S.LEVEL_LOADED,this.onLevelLoaded,this),t.off(S.LEVELS_UPDATED,this.onLevelsUpdated,this),t.off(S.MAX_AUTO_LEVEL_UPDATED,this.onMaxAutoLevelUpdated,this),t.off(S.ERROR,this.onError,this))},e.destroy=function(){this.unregisterListeners(),this.clearTimer(),this.hls=this._abandonRulesCheck=null,this.fragCurrent=this.partCurrent=null},e.onManifestLoading=function(t,e){this.lastLoadedFragLevel=-1,this.firstSelection=-1,this.lastLevelLoadSec=0,this.fragCurrent=this.partCurrent=null,this.onLevelsUpdated(),this.clearTimer()},e.onLevelsUpdated=function(){this.lastLoadedFragLevel>-1&&this.fragCurrent&&(this.lastLoadedFragLevel=this.fragCurrent.level),this._nextAutoLevel=-1,this.onMaxAutoLevelUpdated(),this.codecTiers=null,this.audioTracksByGroup=null},e.onMaxAutoLevelUpdated=function(){this.firstSelection=-1,this.nextAutoLevelKey=""},e.onFragLoading=function(t,e){var r,i=e.frag;this.ignoreFragment(i)||(i.bitrateTest||(this.fragCurrent=i,this.partCurrent=null!=(r=e.part)?r:null),this.clearTimer(),this.timer=self.setInterval(this._abandonRulesCheck,100))},e.onLevelSwitching=function(t,e){this.clearTimer()},e.onError=function(t,e){if(!e.fatal)switch(e.details){case A.BUFFER_ADD_CODEC_ERROR:case A.BUFFER_APPEND_ERROR:this.lastLoadedFragLevel=-1,this.firstSelection=-1;break;case A.FRAG_LOAD_TIMEOUT:var r=e.frag,i=this.fragCurrent,n=this.partCurrent;if(r&&i&&r.sn===i.sn&&r.level===i.level){var a=performance.now(),s=n?n.stats:r.stats,o=a-s.loading.start,l=s.loading.first?s.loading.first-s.loading.start:-1;if(s.loaded&&l>-1){var u=this.bwEstimator.getEstimateTTFB();this.bwEstimator.sample(o-Math.min(u,l),s.loaded)}else this.bwEstimator.sampleTTFB(o)}}},e.getTimeToLoadFrag=function(t,e,r,i){return t+r/e+(i?this.lastLevelLoadSec:0)},e.onLevelLoaded=function(t,e){var r=this.hls.config,i=e.stats.loading,n=i.end-i.start;y(n)&&(this.lastLevelLoadSec=n/1e3),e.details.live?this.bwEstimator.update(r.abrEwmaSlowLive,r.abrEwmaFastLive):this.bwEstimator.update(r.abrEwmaSlowVoD,r.abrEwmaFastVoD)},e.onFragLoaded=function(t,e){var r=e.frag,i=e.part,n=i?i.stats:r.stats;if(r.type===Ie&&this.bwEstimator.sampleTTFB(n.loading.first-n.loading.start),!this.ignoreFragment(r)){if(this.clearTimer(),r.level===this._nextAutoLevel&&(this._nextAutoLevel=-1),this.firstSelection=-1,this.hls.config.abrMaxWithRealBitrate){var a=i?i.duration:r.duration,s=this.hls.levels[r.level],o=(s.loaded?s.loaded.bytes:0)+n.loaded,l=(s.loaded?s.loaded.duration:0)+a;s.loaded={bytes:o,duration:l},s.realBitrate=Math.round(8*o/l)}if(r.bitrateTest){var u={stats:n,frag:r,part:i,id:r.type};this.onFragBuffered(S.FRAG_BUFFERED,u),r.bitrateTest=!1}else this.lastLoadedFragLevel=r.level}},e.onFragBuffered=function(t,e){var r=e.frag,i=e.part,n=null!=i&&i.stats.loaded?i.stats:r.stats;if(!n.aborted&&!this.ignoreFragment(r)){var a=n.parsing.end-n.loading.start-Math.min(n.loading.first-n.loading.start,this.bwEstimator.getEstimateTTFB());this.bwEstimator.sample(a,n.loaded),n.bwEstimate=this.getBwEstimate(),r.bitrateTest?this.bitrateTestDelay=a/1e3:this.bitrateTestDelay=0}},e.ignoreFragment=function(t){return t.type!==Ie||"initSegment"===t.sn},e.clearTimer=function(){this.timer>-1&&(self.clearInterval(this.timer),this.timer=-1)},e.getAutoLevelKey=function(){var t;return this.getBwEstimate()+"_"+(null==(t=this.hls.mainForwardBufferInfo)?void 0:t.len)},e.getNextABRAutoLevel=function(){var t=this.fragCurrent,e=this.partCurrent,r=this.hls,i=r.maxAutoLevel,n=r.config,a=r.minAutoLevel,s=r.media,o=e?e.duration:t?t.duration:0,l=s&&0!==s.playbackRate?Math.abs(s.playbackRate):1,u=this.getBwEstimate(),h=r.mainForwardBufferInfo,d=(h?h.len:0)/l,c=n.abrBandWidthFactor,f=n.abrBandWidthUpFactor;if(d){var g=this.findBestLevel(u,a,i,d,0,c,f);if(g>=0)return g}var v=o?Math.min(o,n.maxStarvationDelay):n.maxStarvationDelay;if(!d){var m=this.bitrateTestDelay;m&&(v=(o?Math.min(o,n.maxLoadingDelay):n.maxLoadingDelay)-m,w.info("[abr] bitrate test took "+Math.round(1e3*m)+"ms, set first fragment max fetchDuration to "+Math.round(1e3*v)+" ms"),c=f=1)}var p=this.findBestLevel(u,a,i,d,v,c,f);if(w.info("[abr] "+(d?"rebuffering expected":"buffer is empty")+", optimal quality level "+p),p>-1)return p;var y=r.levels[a],E=r.levels[r.loadLevel];return(null==y?void 0:y.bitrate)<(null==E?void 0:E.bitrate)?a:r.loadLevel},e.getBwEstimate=function(){return this.bwEstimator.canEstimate()?this.bwEstimator.getEstimate():this.hls.config.abrEwmaDefaultEstimate},e.findBestLevel=function(t,e,r,i,n,a,s){var o,l=this,u=i+n,h=this.lastLoadedFragLevel,d=-1===h?this.hls.firstLevel:h,c=this.fragCurrent,f=this.partCurrent,g=this.hls,v=g.levels,m=g.allAudioTracks,p=g.loadLevel,E=g.config;if(1===v.length)return 0;var T,S=v[d],L=!(null==S||null==(o=S.details)||!o.live),A=-1===p||-1===h,R="SDR",k=(null==S?void 0:S.frameRate)||0,b=E.audioPreference,D=E.videoPreference,I=this.audioTracksByGroup||(this.audioTracksByGroup=function(t){return t.reduce((function(t,e){var r=t.groups[e.groupId];r||(r=t.groups[e.groupId]={tracks:[],channels:{2:0},hasDefault:!1,hasAutoSelect:!1}),r.tracks.push(e);var i=e.channels||"2";return r.channels[i]=(r.channels[i]||0)+1,r.hasDefault=r.hasDefault||e.default,r.hasAutoSelect=r.hasAutoSelect||e.autoselect,r.hasDefault&&(t.hasDefaultAudio=!0),r.hasAutoSelect&&(t.hasAutoSelectAudio=!0),t}),{hasDefaultAudio:!1,hasAutoSelectAudio:!1,groups:{}})}(m));if(A){if(-1!==this.firstSelection)return this.firstSelection;var C=this.codecTiers||(this.codecTiers=function(t,e,r,i){return t.slice(r,i+1).reduce((function(t,r){if(!r.codecSet)return t;var i=r.audioGroups,n=t[r.codecSet];n||(t[r.codecSet]=n={minBitrate:1/0,minHeight:1/0,minFramerate:1/0,maxScore:0,videoRanges:{SDR:0},channels:{2:0},hasDefaultAudio:!i,fragmentError:0}),n.minBitrate=Math.min(n.minBitrate,r.bitrate);var a=Math.min(r.height,r.width);return n.minHeight=Math.min(n.minHeight,a),n.minFramerate=Math.min(n.minFramerate,r.frameRate),n.maxScore=Math.max(n.maxScore,r.score),n.fragmentError+=r.fragmentError,n.videoRanges[r.videoRange]=(n.videoRanges[r.videoRange]||0)+1,i&&i.forEach((function(t){if(t){var r=e.groups[t];n.hasDefaultAudio=n.hasDefaultAudio||e.hasDefaultAudio?r.hasDefault:r.hasAutoSelect||!e.hasDefaultAudio&&!e.hasAutoSelectAudio,Object.keys(r.channels).forEach((function(t){n.channels[t]=(n.channels[t]||0)+r.channels[t]}))}})),t}),{})}(v,I,e,r)),_=function(t,e,r,i,n){for(var a=Object.keys(t),s=null==i?void 0:i.channels,o=null==i?void 0:i.audioCodec,l=s&&2===parseInt(s),u=!0,h=!1,d=1/0,c=1/0,f=1/0,g=0,v=[],m=Pr(e,n),p=m.preferHDR,E=m.allowedVideoRanges,T=function(){var e=t[a[S]];u=e.channels[2]>0,d=Math.min(d,e.minHeight),c=Math.min(c,e.minFramerate),f=Math.min(f,e.minBitrate);var r=E.filter((function(t){return e.videoRanges[t]>0}));r.length>0&&(h=!0,v=r)},S=a.length;S--;)T();d=y(d)?d:0,c=y(c)?c:0;var L=Math.max(1080,d),A=Math.max(30,c);return f=y(f)?f:r,r=Math.max(f,r),h||(e=void 0,v=[]),{codecSet:a.reduce((function(e,i){var n=t[i];if(i===e)return e;if(n.minBitrate>r)return Fr(i,"min bitrate of "+n.minBitrate+" > current estimate of "+r),e;if(!n.hasDefaultAudio)return Fr(i,"no renditions with default or auto-select sound found"),e;if(o&&i.indexOf(o.substring(0,4))%5!=0)return Fr(i,'audio codec preference "'+o+'" not found'),e;if(s&&!l){if(!n.channels[s])return Fr(i,"no renditions with "+s+" channel sound found (channels options: "+Object.keys(n.channels)+")"),e}else if((!o||l)&&u&&0===n.channels[2])return Fr(i,"no renditions with stereo sound found"),e;return n.minHeight>L?(Fr(i,"min resolution of "+n.minHeight+" > maximum of "+L),e):n.minFramerate>A?(Fr(i,"min framerate of "+n.minFramerate+" > maximum of "+A),e):v.some((function(t){return n.videoRanges[t]>0}))?n.maxScore<g?(Fr(i,"max score of "+n.maxScore+" < selected max of "+g),e):e&&(se(i)>=se(e)||n.fragmentError>t[e].fragmentError)?e:(g=n.maxScore,i):(Fr(i,"no variants with VIDEO-RANGE of "+JSON.stringify(v)+" found"),e)}),void 0),videoRanges:v,preferHDR:p,minFramerate:c,minBitrate:f}}(C,R,t,b,D),x=_.codecSet,P=_.videoRanges,F=_.minFramerate,M=_.minBitrate,O=_.preferHDR;T=x,R=O?P[P.length-1]:P[0],k=F,t=Math.max(t,M),w.log("[abr] picked start tier "+JSON.stringify(_))}else T=null==S?void 0:S.codecSet,R=null==S?void 0:S.videoRange;for(var N,U=f?f.duration:c?c.duration:0,B=this.bwEstimator.getEstimateTTFB()/1e3,G=[],K=function(){var e,o,c=v[H],g=H>d;if(!c)return 0;if(E.useMediaCapabilities&&!c.supportedResult&&!c.supportedPromise){var m=navigator.mediaCapabilities;"function"==typeof(null==m?void 0:m.decodingInfo)&&function(t,e,r,i,n,a){var s=t.audioCodec?t.audioGroups:null,o=null==a?void 0:a.audioCodec,l=null==a?void 0:a.channels,u=l?parseInt(l):o?1/0:2,h=null;if(null!=s&&s.length)try{h=1===s.length&&s[0]?e.groups[s[0]].channels:s.reduce((function(t,r){if(r){var i=e.groups[r];if(!i)throw new Error("Audio track group "+r+" not found");Object.keys(i.channels).forEach((function(e){t[e]=(t[e]||0)+i.channels[e]}))}return t}),{2:0})}catch(t){return!0}return void 0!==t.videoCodec&&(t.width>1920&&t.height>1088||t.height>1920&&t.width>1088||t.frameRate>Math.max(i,30)||"SDR"!==t.videoRange&&t.videoRange!==r||t.bitrate>Math.max(n,8e6))||!!h&&y(u)&&Object.keys(h).some((function(t){return parseInt(t)>u}))}(c,I,R,k,t,b)?(c.supportedPromise=xr(c,I,m),c.supportedPromise.then((function(t){c.supportedResult=t;var e=l.hls.levels,r=e.indexOf(c);t.error?w.warn('[abr] MediaCapabilities decodingInfo error: "'+t.error+'" for level '+r+" "+JSON.stringify(t)):t.supported||(w.warn("[abr] Unsupported MediaCapabilities decodingInfo result for level "+r+" "+JSON.stringify(t)),r>-1&&e.length>1&&(w.log("[abr] Removing unsupported level "+r),l.hls.removeLevel(r)))}))):c.supportedResult=Cr}if(T&&c.codecSet!==T||R&&c.videoRange!==R||g&&k>c.frameRate||!g&&k>0&&k<c.frameRate||null==(e=c.supportedResult)||null==(o=e.decodingInfoResults)||!o[0].smooth)return G.push(H),0;var D,C=c.details,_=(f?null==C?void 0:C.partTarget:null==C?void 0:C.averagetargetduration)||U;D=g?s*t:a*t;var x=U&&i>=2*U&&0===n?v[H].averageBitrate:v[H].maxBitrate,P=l.getTimeToLoadFrag(B,D,x*_,void 0===C);if(D>=x&&(H===h||0===c.loadError&&0===c.fragmentError)&&(P<=B||!y(P)||L&&!l.bitrateTestDelay||P<u)){var F=l.forcedAutoLevel;return H===p||-1!==F&&F===p||(G.length&&w.trace("[abr] Skipped level(s) "+G.join(",")+" of "+r+' max with CODECS and VIDEO-RANGE:"'+v[G[0]].codecs+'" '+v[G[0]].videoRange+'; not compatible with "'+S.codecs+'" '+R),w.info("[abr] switch candidate:"+d+"->"+H+" adjustedbw("+Math.round(D)+")-bitrate="+Math.round(D-x)+" ttfb:"+B.toFixed(1)+" avgDuration:"+_.toFixed(1)+" maxFetchDuration:"+u.toFixed(1)+" fetchDuration:"+P.toFixed(1)+" firstSelection:"+A+" codecSet:"+T+" videoRange:"+R+" hls.loadLevel:"+p)),A&&(l.firstSelection=H),{v:H}}},H=r;H>=e;H--)if(0!==(N=K())&&N)return N.v;return-1},s(t,[{key:"firstAutoLevel",get:function(){var t=this.hls,e=t.maxAutoLevel,r=t.minAutoLevel,i=this.getBwEstimate(),n=this.hls.config.maxStarvationDelay,a=this.findBestLevel(i,r,e,0,n,1,1);if(a>-1)return a;var s=this.hls.firstLevel,o=Math.min(Math.max(s,r),e);return w.warn("[abr] Could not find best starting auto level. Defaulting to first in playlist "+s+" clamped to "+o),o}},{key:"forcedAutoLevel",get:function(){return this.nextAutoLevelKey?-1:this._nextAutoLevel}},{key:"nextAutoLevel",get:function(){var t=this.forcedAutoLevel,e=this.bwEstimator.canEstimate(),r=this.lastLoadedFragLevel>-1;if(!(-1===t||e&&r&&this.nextAutoLevelKey!==this.getAutoLevelKey()))return t;var i=e&&r?this.getNextABRAutoLevel():this.firstAutoLevel;if(-1!==t){var n=this.hls.levels;if(n.length>Math.max(t,i)&&n[t].loadError<=n[i].loadError)return t}return this._nextAutoLevel=i,this.nextAutoLevelKey=this.getAutoLevelKey(),i},set:function(t){var e=Math.max(this.hls.minAutoLevel,t);this._nextAutoLevel!=e&&(this.nextAutoLevelKey="",this._nextAutoLevel=e)}}]),t}(),Gr=function(){function t(){this._boundTick=void 0,this._tickTimer=null,this._tickInterval=null,this._tickCallCount=0,this._boundTick=this.tick.bind(this)}var e=t.prototype;return e.destroy=function(){this.onHandlerDestroying(),this.onHandlerDestroyed()},e.onHandlerDestroying=function(){this.clearNextTick(),this.clearInterval()},e.onHandlerDestroyed=function(){},e.hasInterval=function(){return!!this._tickInterval},e.hasNextTick=function(){return!!this._tickTimer},e.setInterval=function(t){return!this._tickInterval&&(this._tickCallCount=0,this._tickInterval=self.setInterval(this._boundTick,t),!0)},e.clearInterval=function(){return!!this._tickInterval&&(self.clearInterval(this._tickInterval),this._tickInterval=null,!0)},e.clearNextTick=function(){return!!this._tickTimer&&(self.clearTimeout(this._tickTimer),this._tickTimer=null,!0)},e.tick=function(){this._tickCallCount++,1===this._tickCallCount&&(this.doTick(),this._tickCallCount>1&&this.tickImmediate(),this._tickCallCount=0)},e.tickImmediate=function(){this.clearNextTick(),this._tickTimer=self.setTimeout(this._boundTick,0)},e.doTick=function(){},t}(),Kr="NOT_LOADED",Hr="APPENDING",Vr="PARTIAL",Yr="OK",Wr=function(){function t(t){this.activePartLists=Object.create(null),this.endListFragments=Object.create(null),this.fragments=Object.create(null),this.timeRanges=Object.create(null),this.bufferPadding=.2,this.hls=void 0,this.hasGaps=!1,this.hls=t,this._registerListeners()}var e=t.prototype;return e._registerListeners=function(){var t=this.hls;t.on(S.BUFFER_APPENDED,this.onBufferAppended,this),t.on(S.FRAG_BUFFERED,this.onFragBuffered,this),t.on(S.FRAG_LOADED,this.onFragLoaded,this)},e._unregisterListeners=function(){var t=this.hls;t.off(S.BUFFER_APPENDED,this.onBufferAppended,this),t.off(S.FRAG_BUFFERED,this.onFragBuffered,this),t.off(S.FRAG_LOADED,this.onFragLoaded,this)},e.destroy=function(){this._unregisterListeners(),this.fragments=this.activePartLists=this.endListFragments=this.timeRanges=null},e.getAppendedFrag=function(t,e){var r=this.activePartLists[e];if(r)for(var i=r.length;i--;){var n=r[i];if(!n)break;var a=n.end;if(n.start<=t&&null!==a&&t<=a)return n}return this.getBufferedFrag(t,e)},e.getBufferedFrag=function(t,e){for(var r=this.fragments,i=Object.keys(r),n=i.length;n--;){var a=r[i[n]];if((null==a?void 0:a.body.type)===e&&a.buffered){var s=a.body;if(s.start<=t&&t<=s.end)return s}}return null},e.detectEvictedFragments=function(t,e,r,i){var n=this;this.timeRanges&&(this.timeRanges[t]=e);var a=(null==i?void 0:i.fragment.sn)||-1;Object.keys(this.fragments).forEach((function(i){var s=n.fragments[i];if(s&&!(a>=s.body.sn))if(s.buffered||s.loaded){var o=s.range[t];o&&o.time.some((function(t){var r=!n.isTimeBuffered(t.startPTS,t.endPTS,e);return r&&n.removeFragment(s.body),r}))}else s.body.type===r&&n.removeFragment(s.body)}))},e.detectPartialFragments=function(t){var e=this,r=this.timeRanges,i=t.frag,n=t.part;if(r&&"initSegment"!==i.sn){var a=qr(i),s=this.fragments[a];if(!(!s||s.buffered&&i.gap)){var o=!i.relurl;Object.keys(r).forEach((function(t){var a=i.elementaryStreams[t];if(a){var l=r[t],u=o||!0===a.partial;s.range[t]=e.getBufferedTimes(i,n,u,l)}})),s.loaded=null,Object.keys(s.range).length?(s.buffered=!0,(s.body.endList=i.endList||s.body.endList)&&(this.endListFragments[s.body.type]=s),jr(s)||this.removeParts(i.sn-1,i.type)):this.removeFragment(s.body)}}},e.removeParts=function(t,e){var r=this.activePartLists[e];r&&(this.activePartLists[e]=r.filter((function(e){return e.fragment.sn>=t})))},e.fragBuffered=function(t,e){var r=qr(t),i=this.fragments[r];!i&&e&&(i=this.fragments[r]={body:t,appendedPTS:null,loaded:null,buffered:!1,range:Object.create(null)},t.gap&&(this.hasGaps=!0)),i&&(i.loaded=null,i.buffered=!0)},e.getBufferedTimes=function(t,e,r,i){for(var n={time:[],partial:r},a=t.start,s=t.end,o=t.minEndPTS||s,l=t.maxStartPTS||a,u=0;u<i.length;u++){var h=i.start(u)-this.bufferPadding,d=i.end(u)+this.bufferPadding;if(l>=h&&o<=d){n.time.push({startPTS:Math.max(a,i.start(u)),endPTS:Math.min(s,i.end(u))});break}if(a<d&&s>h){var c=Math.max(a,i.start(u)),f=Math.min(s,i.end(u));f>c&&(n.partial=!0,n.time.push({startPTS:c,endPTS:f}))}else if(s<=h)break}return n},e.getPartialFragment=function(t){var e,r,i,n=null,a=0,s=this.bufferPadding,o=this.fragments;return Object.keys(o).forEach((function(l){var u=o[l];u&&jr(u)&&(r=u.body.start-s,i=u.body.end+s,t>=r&&t<=i&&(e=Math.min(t-r,i-t),a<=e&&(n=u.body,a=e)))})),n},e.isEndListAppended=function(t){var e=this.endListFragments[t];return void 0!==e&&(e.buffered||jr(e))},e.getState=function(t){var e=qr(t),r=this.fragments[e];return r?r.buffered?jr(r)?Vr:Yr:Hr:Kr},e.isTimeBuffered=function(t,e,r){for(var i,n,a=0;a<r.length;a++){if(i=r.start(a)-this.bufferPadding,n=r.end(a)+this.bufferPadding,t>=i&&e<=n)return!0;if(e<=i)return!1}return!1},e.onFragLoaded=function(t,e){var r=e.frag,i=e.part;if("initSegment"!==r.sn&&!r.bitrateTest){var n=i?null:e,a=qr(r);this.fragments[a]={body:r,appendedPTS:null,loaded:n,buffered:!1,range:Object.create(null)}}},e.onBufferAppended=function(t,e){var r=this,i=e.frag,n=e.part,a=e.timeRanges;if("initSegment"!==i.sn){var s=i.type;if(n){var o=this.activePartLists[s];o||(this.activePartLists[s]=o=[]),o.push(n)}this.timeRanges=a,Object.keys(a).forEach((function(t){var e=a[t];r.detectEvictedFragments(t,e,s,n)}))}},e.onFragBuffered=function(t,e){this.detectPartialFragments(e)},e.hasFragment=function(t){var e=qr(t);return!!this.fragments[e]},e.hasParts=function(t){var e;return!(null==(e=this.activePartLists[t])||!e.length)},e.removeFragmentsInRange=function(t,e,r,i,n){var a=this;i&&!this.hasGaps||Object.keys(this.fragments).forEach((function(s){var o=a.fragments[s];if(o){var l=o.body;l.type!==r||i&&!l.gap||l.start<e&&l.end>t&&(o.buffered||n)&&a.removeFragment(l)}}))},e.removeFragment=function(t){var e=qr(t);t.stats.loaded=0,t.clearElementaryStreamInfo();var r=this.activePartLists[t.type];if(r){var i=t.sn;this.activePartLists[t.type]=r.filter((function(t){return t.fragment.sn!==i}))}delete this.fragments[e],t.endList&&delete this.endListFragments[t.type]},e.removeAllFragments=function(){this.fragments=Object.create(null),this.endListFragments=Object.create(null),this.activePartLists=Object.create(null),this.hasGaps=!1},t}();function jr(t){var e,r,i;return t.buffered&&(t.body.gap||(null==(e=t.range.video)?void 0:e.partial)||(null==(r=t.range.audio)?void 0:r.partial)||(null==(i=t.range.audiovideo)?void 0:i.partial))}function qr(t){return t.type+"_"+t.level+"_"+t.sn}var Xr={length:0,start:function(){return 0},end:function(){return 0}},zr=function(){function t(){}return t.isBuffered=function(e,r){try{if(e)for(var i=t.getBuffered(e),n=0;n<i.length;n++)if(r>=i.start(n)&&r<=i.end(n))return!0}catch(t){}return!1},t.bufferInfo=function(e,r,i){try{if(e){var n,a=t.getBuffered(e),s=[];for(n=0;n<a.length;n++)s.push({start:a.start(n),end:a.end(n)});return this.bufferedInfo(s,r,i)}}catch(t){}return{len:0,start:r,end:r,nextStart:void 0}},t.bufferedInfo=function(t,e,r){e=Math.max(0,e),t.sort((function(t,e){var r=t.start-e.start;return r||e.end-t.end}));var i=[];if(r)for(var n=0;n<t.length;n++){var a=i.length;if(a){var s=i[a-1].end;t[n].start-s<r?t[n].end>s&&(i[a-1].end=t[n].end):i.push(t[n])}else i.push(t[n])}else i=t;for(var o,l=0,u=e,h=e,d=0;d<i.length;d++){var c=i[d].start,f=i[d].end;if(e+r>=c&&e<f)u=c,l=(h=f)-e;else if(e+r<c){o=c;break}}return{len:l,start:u||0,end:h||0,nextStart:o}},t.getBuffered=function(t){try{return t.buffered}catch(t){return w.log("failed to get media.buffered",t),Xr}},t}(),Qr=function(t,e,r,i,n,a){void 0===i&&(i=0),void 0===n&&(n=-1),void 0===a&&(a=!1),this.level=void 0,this.sn=void 0,this.part=void 0,this.id=void 0,this.size=void 0,this.partial=void 0,this.transmuxing={start:0,executeStart:0,executeEnd:0,end:0},this.buffering={audio:{start:0,executeStart:0,executeEnd:0,end:0},video:{start:0,executeStart:0,executeEnd:0,end:0},audiovideo:{start:0,executeStart:0,executeEnd:0,end:0}},this.level=t,this.sn=e,this.id=r,this.size=i,this.part=n,this.partial=a};function Jr(t,e){for(var r=0,i=t.length;r<i;r++){var n;if((null==(n=t[r])?void 0:n.cc)===e)return t[r]}return null}function $r(t,e){if(t){var r=t.start+e;t.start=t.startPTS=r,t.endPTS=r+t.duration}}function Zr(t,e){for(var r=e.fragments,i=0,n=r.length;i<n;i++)$r(r[i],t);e.fragmentHint&&$r(e.fragmentHint,t),e.alignedSliding=!0}function ti(t,e,r){e&&(function(t,e,r){if(function(t,e,r){return!(!e||!(r.endCC>r.startCC||t&&t.cc<r.startCC))}(t,r,e)){var i=function(t,e){var r=t.fragments,i=e.fragments;if(i.length&&r.length){var n=Jr(r,i[0].cc);if(n&&(!n||n.startPTS))return n;w.log("No frag in previous level to align on")}else w.log("No fragments to align")}(r,e);i&&y(i.start)&&(w.log("Adjusting PTS using last level due to CC increase within current level "+e.url),Zr(i.start,e))}}(t,r,e),!r.alignedSliding&&e&&ei(r,e),r.alignedSliding||!e||r.skippedSegments||ar(e,r))}function ei(t,e){if(t.hasProgramDateTime&&e.hasProgramDateTime){var r=t.fragments,i=e.fragments;if(r.length&&i.length){var n,a,s=Math.min(e.endCC,t.endCC);e.startCC<s&&t.startCC<s&&(n=Jr(i,s),a=Jr(r,s)),n&&a||(a=Jr(r,(n=i[Math.floor(i.length/2)]).cc)||r[Math.floor(r.length/2)]);var o=n.programDateTime,l=a.programDateTime;o&&l&&Zr((l-o)/1e3-(a.start-n.start),t)}}}var ri=Math.pow(2,17),ii=function(){function t(t){this.config=void 0,this.loader=null,this.partLoadTimeout=-1,this.config=t}var e=t.prototype;return e.destroy=function(){this.loader&&(this.loader.destroy(),this.loader=null)},e.abort=function(){this.loader&&this.loader.abort()},e.load=function(t,e){var r=this,n=t.url;if(!n)return Promise.reject(new si({type:L.NETWORK_ERROR,details:A.FRAG_LOAD_ERROR,fatal:!1,frag:t,error:new Error("Fragment does not have a "+(n?"part list":"url")),networkDetails:null}));this.abort();var a=this.config,s=a.fLoader,o=a.loader;return new Promise((function(l,u){if(r.loader&&r.loader.destroy(),t.gap){if(t.tagList.some((function(t){return"GAP"===t[0]})))return void u(ai(t));t.gap=!1}var h=r.loader=t.loader=s?new s(a):new o(a),d=ni(t),c=fr(a.fragLoadPolicy.default),f={loadPolicy:c,timeout:c.maxLoadTimeMs,maxRetry:0,retryDelay:0,maxRetryDelay:0,highWaterMark:"initSegment"===t.sn?1/0:ri};t.stats=h.stats,h.load(d,f,{onSuccess:function(e,i,n,a){r.resetLoader(t,h);var s=e.data;n.resetIV&&t.decryptdata&&(t.decryptdata.iv=new Uint8Array(s.slice(0,16)),s=s.slice(16)),l({frag:t,part:null,payload:s,networkDetails:a})},onError:function(e,a,s,o){r.resetLoader(t,h),u(new si({type:L.NETWORK_ERROR,details:A.FRAG_LOAD_ERROR,fatal:!1,frag:t,response:i({url:n,data:void 0},e),error:new Error("HTTP Error "+e.code+" "+e.text),networkDetails:s,stats:o}))},onAbort:function(e,i,n){r.resetLoader(t,h),u(new si({type:L.NETWORK_ERROR,details:A.INTERNAL_ABORTED,fatal:!1,frag:t,error:new Error("Aborted"),networkDetails:n,stats:e}))},onTimeout:function(e,i,n){r.resetLoader(t,h),u(new si({type:L.NETWORK_ERROR,details:A.FRAG_LOAD_TIMEOUT,fatal:!1,frag:t,error:new Error("Timeout after "+f.timeout+"ms"),networkDetails:n,stats:e}))},onProgress:function(r,i,n,a){e&&e({frag:t,part:null,payload:n,networkDetails:a})}})}))},e.loadPart=function(t,e,r){var n=this;this.abort();var a=this.config,s=a.fLoader,o=a.loader;return new Promise((function(l,u){if(n.loader&&n.loader.destroy(),t.gap||e.gap)u(ai(t,e));else{var h=n.loader=t.loader=s?new s(a):new o(a),d=ni(t,e),c=fr(a.fragLoadPolicy.default),f={loadPolicy:c,timeout:c.maxLoadTimeMs,maxRetry:0,retryDelay:0,maxRetryDelay:0,highWaterMark:ri};e.stats=h.stats,h.load(d,f,{onSuccess:function(i,a,s,o){n.resetLoader(t,h),n.updateStatsFromPart(t,e);var u={frag:t,part:e,payload:i.data,networkDetails:o};r(u),l(u)},onError:function(r,a,s,o){n.resetLoader(t,h),u(new si({type:L.NETWORK_ERROR,details:A.FRAG_LOAD_ERROR,fatal:!1,frag:t,part:e,response:i({url:d.url,data:void 0},r),error:new Error("HTTP Error "+r.code+" "+r.text),networkDetails:s,stats:o}))},onAbort:function(r,i,a){t.stats.aborted=e.stats.aborted,n.resetLoader(t,h),u(new si({type:L.NETWORK_ERROR,details:A.INTERNAL_ABORTED,fatal:!1,frag:t,part:e,error:new Error("Aborted"),networkDetails:a,stats:r}))},onTimeout:function(r,i,a){n.resetLoader(t,h),u(new si({type:L.NETWORK_ERROR,details:A.FRAG_LOAD_TIMEOUT,fatal:!1,frag:t,part:e,error:new Error("Timeout after "+f.timeout+"ms"),networkDetails:a,stats:r}))}})}}))},e.updateStatsFromPart=function(t,e){var r=t.stats,i=e.stats,n=i.total;if(r.loaded+=i.loaded,n){var a=Math.round(t.duration/e.duration),s=Math.min(Math.round(r.loaded/n),a),o=(a-s)*Math.round(r.loaded/s);r.total=r.loaded+o}else r.total=Math.max(r.loaded,r.total);var l=r.loading,u=i.loading;l.start?l.first+=u.first-u.start:(l.start=u.start,l.first=u.first),l.end=u.end},e.resetLoader=function(t,e){t.loader=null,this.loader===e&&(self.clearTimeout(this.partLoadTimeout),this.loader=null),e.destroy()},t}();function ni(t,e){void 0===e&&(e=null);var r=e||t,i={frag:t,part:e,responseType:"arraybuffer",url:r.url,headers:{},rangeStart:0,rangeEnd:0},n=r.byteRangeStartOffset,a=r.byteRangeEndOffset;if(y(n)&&y(a)){var s,o=n,l=a;if("initSegment"===t.sn&&"AES-128"===(null==(s=t.decryptdata)?void 0:s.method)){var u=a-n;u%16&&(l=a+(16-u%16)),0!==n&&(i.resetIV=!0,o=n-16)}i.rangeStart=o,i.rangeEnd=l}return i}function ai(t,e){var r=new Error("GAP "+(t.gap?"tag":"attribute")+" found"),i={type:L.MEDIA_ERROR,details:A.FRAG_GAP,fatal:!1,frag:t,error:r,networkDetails:null};return e&&(i.part=e),(e||t).stats.aborted=!0,new si(i)}var si=function(t){function e(e){var r;return(r=t.call(this,e.error.message)||this).data=void 0,r.data=e,r}return l(e,t),e}(c(Error)),oi=function(){function t(t,e){this.subtle=void 0,this.aesIV=void 0,this.subtle=t,this.aesIV=e}return t.prototype.decrypt=function(t,e){return this.subtle.decrypt({name:"AES-CBC",iv:this.aesIV},e,t)},t}(),li=function(){function t(t,e){this.subtle=void 0,this.key=void 0,this.subtle=t,this.key=e}return t.prototype.expandKey=function(){return this.subtle.importKey("raw",this.key,{name:"AES-CBC"},!1,["encrypt","decrypt"])},t}(),ui=function(){function t(){this.rcon=[0,1,2,4,8,16,32,64,128,27,54],this.subMix=[new Uint32Array(256),new Uint32Array(256),new Uint32Array(256),new Uint32Array(256)],this.invSubMix=[new Uint32Array(256),new Uint32Array(256),new Uint32Array(256),new Uint32Array(256)],this.sBox=new Uint32Array(256),this.invSBox=new Uint32Array(256),this.key=new Uint32Array(0),this.ksRows=0,this.keySize=0,this.keySchedule=void 0,this.invKeySchedule=void 0,this.initTable()}var e=t.prototype;return e.uint8ArrayToUint32Array_=function(t){for(var e=new DataView(t),r=new Uint32Array(4),i=0;i<4;i++)r[i]=e.getUint32(4*i);return r},e.initTable=function(){var t=this.sBox,e=this.invSBox,r=this.subMix,i=r[0],n=r[1],a=r[2],s=r[3],o=this.invSubMix,l=o[0],u=o[1],h=o[2],d=o[3],c=new Uint32Array(256),f=0,g=0,v=0;for(v=0;v<256;v++)c[v]=v<128?v<<1:v<<1^283;for(v=0;v<256;v++){var m=g^g<<1^g<<2^g<<3^g<<4;m=m>>>8^255&m^99,t[f]=m,e[m]=f;var p=c[f],y=c[p],E=c[y],T=257*c[m]^16843008*m;i[f]=T<<24|T>>>8,n[f]=T<<16|T>>>16,a[f]=T<<8|T>>>24,s[f]=T,T=16843009*E^65537*y^257*p^16843008*f,l[m]=T<<24|T>>>8,u[m]=T<<16|T>>>16,h[m]=T<<8|T>>>24,d[m]=T,f?(f=p^c[c[c[E^p]]],g^=c[c[g]]):f=g=1}},e.expandKey=function(t){for(var e=this.uint8ArrayToUint32Array_(t),r=!0,i=0;i<e.length&&r;)r=e[i]===this.key[i],i++;if(!r){this.key=e;var n=this.keySize=e.length;if(4!==n&&6!==n&&8!==n)throw new Error("Invalid aes key size="+n);var a,s,o,l,u=this.ksRows=4*(n+6+1),h=this.keySchedule=new Uint32Array(u),d=this.invKeySchedule=new Uint32Array(u),c=this.sBox,f=this.rcon,g=this.invSubMix,v=g[0],m=g[1],p=g[2],y=g[3];for(a=0;a<u;a++)a<n?o=h[a]=e[a]:(l=o,a%n==0?(l=c[(l=l<<8|l>>>24)>>>24]<<24|c[l>>>16&255]<<16|c[l>>>8&255]<<8|c[255&l],l^=f[a/n|0]<<24):n>6&&a%n==4&&(l=c[l>>>24]<<24|c[l>>>16&255]<<16|c[l>>>8&255]<<8|c[255&l]),h[a]=o=(h[a-n]^l)>>>0);for(s=0;s<u;s++)a=u-s,l=3&s?h[a]:h[a-4],d[s]=s<4||a<=4?l:v[c[l>>>24]]^m[c[l>>>16&255]]^p[c[l>>>8&255]]^y[c[255&l]],d[s]=d[s]>>>0}},e.networkToHostOrderSwap=function(t){return t<<24|(65280&t)<<8|(16711680&t)>>8|t>>>24},e.decrypt=function(t,e,r){for(var i,n,a,s,o,l,u,h,d,c,f,g,v,m,p=this.keySize+6,y=this.invKeySchedule,E=this.invSBox,T=this.invSubMix,S=T[0],L=T[1],A=T[2],R=T[3],k=this.uint8ArrayToUint32Array_(r),b=k[0],D=k[1],I=k[2],w=k[3],C=new Int32Array(t),_=new Int32Array(C.length),x=this.networkToHostOrderSwap;e<C.length;){for(d=x(C[e]),c=x(C[e+1]),f=x(C[e+2]),g=x(C[e+3]),o=d^y[0],l=g^y[1],u=f^y[2],h=c^y[3],v=4,m=1;m<p;m++)i=S[o>>>24]^L[l>>16&255]^A[u>>8&255]^R[255&h]^y[v],n=S[l>>>24]^L[u>>16&255]^A[h>>8&255]^R[255&o]^y[v+1],a=S[u>>>24]^L[h>>16&255]^A[o>>8&255]^R[255&l]^y[v+2],s=S[h>>>24]^L[o>>16&255]^A[l>>8&255]^R[255&u]^y[v+3],o=i,l=n,u=a,h=s,v+=4;i=E[o>>>24]<<24^E[l>>16&255]<<16^E[u>>8&255]<<8^E[255&h]^y[v],n=E[l>>>24]<<24^E[u>>16&255]<<16^E[h>>8&255]<<8^E[255&o]^y[v+1],a=E[u>>>24]<<24^E[h>>16&255]<<16^E[o>>8&255]<<8^E[255&l]^y[v+2],s=E[h>>>24]<<24^E[o>>16&255]<<16^E[l>>8&255]<<8^E[255&u]^y[v+3],_[e]=x(i^b),_[e+1]=x(s^D),_[e+2]=x(a^I),_[e+3]=x(n^w),b=d,D=c,I=f,w=g,e+=4}return _.buffer},t}(),hi=function(){function t(t,e){var r=(void 0===e?{}:e).removePKCS7Padding,i=void 0===r||r;if(this.logEnabled=!0,this.removePKCS7Padding=void 0,this.subtle=null,this.softwareDecrypter=null,this.key=null,this.fastAesKey=null,this.remainderData=null,this.currentIV=null,this.currentResult=null,this.useSoftware=void 0,this.useSoftware=t.enableSoftwareAES,this.removePKCS7Padding=i,i)try{var n=self.crypto;n&&(this.subtle=n.subtle||n.webkitSubtle)}catch(t){}null===this.subtle&&(this.useSoftware=!0)}var e=t.prototype;return e.destroy=function(){this.subtle=null,this.softwareDecrypter=null,this.key=null,this.fastAesKey=null,this.remainderData=null,this.currentIV=null,this.currentResult=null},e.isSync=function(){return this.useSoftware},e.flush=function(){var t=this.currentResult,e=this.remainderData;if(!t||e)return this.reset(),null;var r,i,n,a=new Uint8Array(t);return this.reset(),this.removePKCS7Padding?(i=(r=a).byteLength,(n=i&&new DataView(r.buffer).getUint8(i-1))?nt(r,0,i-n):r):a},e.reset=function(){this.currentResult=null,this.currentIV=null,this.remainderData=null,this.softwareDecrypter&&(this.softwareDecrypter=null)},e.decrypt=function(t,e,r){var i=this;return this.useSoftware?new Promise((function(n,a){i.softwareDecrypt(new Uint8Array(t),e,r);var s=i.flush();s?n(s.buffer):a(new Error("[softwareDecrypt] Failed to decrypt data"))})):this.webCryptoDecrypt(new Uint8Array(t),e,r)},e.softwareDecrypt=function(t,e,r){var i=this.currentIV,n=this.currentResult,a=this.remainderData;this.logOnce("JS AES decrypt"),a&&(t=Gt(a,t),this.remainderData=null);var s=this.getValidChunk(t);if(!s.length)return null;i&&(r=i);var o=this.softwareDecrypter;o||(o=this.softwareDecrypter=new ui),o.expandKey(e);var l=n;return this.currentResult=o.decrypt(s.buffer,0,r),this.currentIV=nt(s,-16).buffer,l||null},e.webCryptoDecrypt=function(t,e,r){var i=this,n=this.subtle;return this.key===e&&this.fastAesKey||(this.key=e,this.fastAesKey=new li(n,e)),this.fastAesKey.expandKey().then((function(e){return n?(i.logOnce("WebCrypto AES decrypt"),new oi(n,new Uint8Array(r)).decrypt(t.buffer,e)):Promise.reject(new Error("web crypto not initialized"))})).catch((function(n){return w.warn("[decrypter]: WebCrypto Error, disable WebCrypto API, "+n.name+": "+n.message),i.onWebCryptoError(t,e,r)}))},e.onWebCryptoError=function(t,e,r){this.useSoftware=!0,this.logEnabled=!0,this.softwareDecrypt(t,e,r);var i=this.flush();if(i)return i.buffer;throw new Error("WebCrypto and softwareDecrypt: failed to decrypt data")},e.getValidChunk=function(t){var e=t,r=t.length-t.length%16;return r!==t.length&&(e=nt(t,0,r),this.remainderData=nt(t,r)),e},e.logOnce=function(t){this.logEnabled&&(w.log("[decrypter]: "+t),this.logEnabled=!1)},t}(),di=function(t){for(var e="",r=t.length,i=0;i<r;i++)e+="["+t.start(i).toFixed(3)+"-"+t.end(i).toFixed(3)+"]";return e},ci="STOPPED",fi="IDLE",gi="KEY_LOADING",vi="FRAG_LOADING",mi="FRAG_LOADING_WAITING_RETRY",pi="WAITING_TRACK",yi="PARSING",Ei="PARSED",Ti="ENDED",Si="ERROR",Li="WAITING_INIT_PTS",Ai="WAITING_LEVEL",Ri=function(t){function e(e,r,i,n,a){var s;return(s=t.call(this)||this).hls=void 0,s.fragPrevious=null,s.fragCurrent=null,s.fragmentTracker=void 0,s.transmuxer=null,s._state=ci,s.playlistType=void 0,s.media=null,s.mediaBuffer=null,s.config=void 0,s.bitrateTest=!1,s.lastCurrentTime=0,s.nextLoadPosition=0,s.startPosition=0,s.startTimeOffset=null,s.loadedmetadata=!1,s.retryDate=0,s.levels=null,s.fragmentLoader=void 0,s.keyLoader=void 0,s.levelLastLoaded=null,s.startFragRequested=!1,s.decrypter=void 0,s.initPTS=[],s.onvseeking=null,s.onvended=null,s.logPrefix="",s.log=void 0,s.warn=void 0,s.playlistType=a,s.logPrefix=n,s.log=w.log.bind(w,n+":"),s.warn=w.warn.bind(w,n+":"),s.hls=e,s.fragmentLoader=new ii(e.config),s.keyLoader=i,s.fragmentTracker=r,s.config=e.config,s.decrypter=new hi(e.config),e.on(S.MANIFEST_LOADED,s.onManifestLoaded,function(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}(s)),s}l(e,t);var r=e.prototype;return r.doTick=function(){this.onTickEnd()},r.onTickEnd=function(){},r.startLoad=function(t){},r.stopLoad=function(){this.fragmentLoader.abort(),this.keyLoader.abort(this.playlistType);var t=this.fragCurrent;null!=t&&t.loader&&(t.abortRequests(),this.fragmentTracker.removeFragment(t)),this.resetTransmuxer(),this.fragCurrent=null,this.fragPrevious=null,this.clearInterval(),this.clearNextTick(),this.state=ci},r._streamEnded=function(t,e){if(e.live||t.nextStart||!t.end||!this.media)return!1;var r=e.partList;if(null!=r&&r.length){var i=r[r.length-1];return zr.isBuffered(this.media,i.start+i.duration/2)}var n=e.fragments[e.fragments.length-1].type;return this.fragmentTracker.isEndListAppended(n)},r.getLevelDetails=function(){var t;if(this.levels&&null!==this.levelLastLoaded)return null==(t=this.levelLastLoaded)?void 0:t.details},r.onMediaAttached=function(t,e){var r=this.media=this.mediaBuffer=e.media;this.onvseeking=this.onMediaSeeking.bind(this),this.onvended=this.onMediaEnded.bind(this),r.addEventListener("seeking",this.onvseeking),r.addEventListener("ended",this.onvended);var i=this.config;this.levels&&i.autoStartLoad&&this.state===ci&&this.startLoad(i.startPosition)},r.onMediaDetaching=function(){var t=this.media;null!=t&&t.ended&&(this.log("MSE detaching and video ended, reset startPosition"),this.startPosition=this.lastCurrentTime=0),t&&this.onvseeking&&this.onvended&&(t.removeEventListener("seeking",this.onvseeking),t.removeEventListener("ended",this.onvended),this.onvseeking=this.onvended=null),this.keyLoader&&this.keyLoader.detach(),this.media=this.mediaBuffer=null,this.loadedmetadata=!1,this.fragmentTracker.removeAllFragments(),this.stopLoad()},r.onMediaSeeking=function(){var t=this.config,e=this.fragCurrent,r=this.media,i=this.mediaBuffer,n=this.state,a=r?r.currentTime:0,s=zr.bufferInfo(i||r,a,t.maxBufferHole);if(this.log("media seeking to "+(y(a)?a.toFixed(3):a)+", state: "+n),this.state===Ti)this.resetLoadingState();else if(e){var o=t.maxFragLookUpTolerance,l=e.start-o,u=e.start+e.duration+o;if(!s.len||u<s.start||l>s.end){var h=a>u;(a<l||h)&&(h&&e.loader&&(this.log("seeking outside of buffer while fragment load in progress, cancel fragment load"),e.abortRequests(),this.resetLoadingState()),this.fragPrevious=null)}}r&&(this.fragmentTracker.removeFragmentsInRange(a,1/0,this.playlistType,!0),this.lastCurrentTime=a),this.loadedmetadata||s.len||(this.nextLoadPosition=this.startPosition=a),this.tickImmediate()},r.onMediaEnded=function(){this.startPosition=this.lastCurrentTime=0},r.onManifestLoaded=function(t,e){this.startTimeOffset=e.startTimeOffset,this.initPTS=[]},r.onHandlerDestroying=function(){this.hls.off(S.MANIFEST_LOADED,this.onManifestLoaded,this),this.stopLoad(),t.prototype.onHandlerDestroying.call(this),this.hls=null},r.onHandlerDestroyed=function(){this.state=ci,this.fragmentLoader&&this.fragmentLoader.destroy(),this.keyLoader&&this.keyLoader.destroy(),this.decrypter&&this.decrypter.destroy(),this.hls=this.log=this.warn=this.decrypter=this.keyLoader=this.fragmentLoader=this.fragmentTracker=null,t.prototype.onHandlerDestroyed.call(this)},r.loadFragment=function(t,e,r){this._loadFragForPlayback(t,e,r)},r._loadFragForPlayback=function(t,e,r){var i=this;this._doFragLoad(t,e,r,(function(e){if(i.fragContextChanged(t))return i.warn("Fragment "+t.sn+(e.part?" p: "+e.part.index:"")+" of level "+t.level+" was dropped during download."),void i.fragmentTracker.removeFragment(t);t.stats.chunkCount++,i._handleFragmentLoadProgress(e)})).then((function(e){if(e){var r=i.state;i.fragContextChanged(t)?(r===vi||!i.fragCurrent&&r===yi)&&(i.fragmentTracker.removeFragment(t),i.state=fi):("payload"in e&&(i.log("Loaded fragment "+t.sn+" of level "+t.level),i.hls.trigger(S.FRAG_LOADED,e)),i._handleFragmentLoadComplete(e))}})).catch((function(e){i.state!==ci&&i.state!==Si&&(i.warn(e),i.resetFragmentLoading(t))}))},r.clearTrackerIfNeeded=function(t){var e,r=this.fragmentTracker;if(r.getState(t)===Hr){var i=t.type,n=this.getFwdBufferInfo(this.mediaBuffer,i),a=Math.max(t.duration,n?n.len:this.config.maxBufferLength);this.reduceMaxBufferLength(a)&&r.removeFragment(t)}else 0===(null==(e=this.mediaBuffer)?void 0:e.buffered.length)?r.removeAllFragments():r.hasParts(t.type)&&(r.detectPartialFragments({frag:t,part:null,stats:t.stats,id:t.type}),r.getState(t)===Vr&&r.removeFragment(t))},r.checkLiveUpdate=function(t){if(t.updated&&!t.live){var e=t.fragments[t.fragments.length-1];this.fragmentTracker.detectPartialFragments({frag:e,part:null,stats:e.stats,id:e.type})}t.fragments[0]||(t.deltaUpdateFailed=!0)},r.flushMainBuffer=function(t,e,r){if(void 0===r&&(r=null),t-e){var i={startOffset:t,endOffset:e,type:r};this.hls.trigger(S.BUFFER_FLUSHING,i)}},r._loadInitSegment=function(t,e){var r=this;this._doFragLoad(t,e).then((function(e){if(!e||r.fragContextChanged(t)||!r.levels)throw new Error("init load aborted");return e})).then((function(e){var i=r.hls,n=e.payload,a=t.decryptdata;if(n&&n.byteLength>0&&null!=a&&a.key&&a.iv&&"AES-128"===a.method){var s=self.performance.now();return r.decrypter.decrypt(new Uint8Array(n),a.key.buffer,a.iv.buffer).catch((function(e){throw i.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_DECRYPT_ERROR,fatal:!1,error:e,reason:e.message,frag:t}),e})).then((function(n){var a=self.performance.now();return i.trigger(S.FRAG_DECRYPTED,{frag:t,payload:n,stats:{tstart:s,tdecrypt:a}}),e.payload=n,r.completeInitSegmentLoad(e)}))}return r.completeInitSegmentLoad(e)})).catch((function(e){r.state!==ci&&r.state!==Si&&(r.warn(e),r.resetFragmentLoading(t))}))},r.completeInitSegmentLoad=function(t){if(!this.levels)throw new Error("init load aborted, missing levels");var e=t.frag.stats;this.state=fi,t.frag.data=new Uint8Array(t.payload),e.parsing.start=e.buffering.start=self.performance.now(),e.parsing.end=e.buffering.end=self.performance.now(),this.tick()},r.fragContextChanged=function(t){var e=this.fragCurrent;return!t||!e||t.sn!==e.sn||t.level!==e.level},r.fragBufferedComplete=function(t,e){var r,i,n,a,s=this.mediaBuffer?this.mediaBuffer:this.media;if(this.log("Buffered "+t.type+" sn: "+t.sn+(e?" part: "+e.index:"")+" of "+(this.playlistType===Ie?"level":"track")+" "+t.level+" (frag:["+(null!=(r=t.startPTS)?r:NaN).toFixed(3)+"-"+(null!=(i=t.endPTS)?i:NaN).toFixed(3)+"] > buffer:"+(s?di(zr.getBuffered(s)):"(detached)")+")"),"initSegment"!==t.sn){var o;if(t.type!==Ce){var l=t.elementaryStreams;if(!Object.keys(l).some((function(t){return!!l[t]})))return void(this.state=fi)}var u=null==(o=this.levels)?void 0:o[t.level];null!=u&&u.fragmentError&&(this.log("Resetting level fragment error count of "+u.fragmentError+" on frag buffered"),u.fragmentError=0)}this.state=fi,s&&(!this.loadedmetadata&&t.type==Ie&&s.buffered.length&&(null==(n=this.fragCurrent)?void 0:n.sn)===(null==(a=this.fragPrevious)?void 0:a.sn)&&(this.loadedmetadata=!0,this.seekToStartPos()),this.tick())},r.seekToStartPos=function(){},r._handleFragmentLoadComplete=function(t){var e=this.transmuxer;if(e){var r=t.frag,i=t.part,n=t.partsLoaded,a=!n||0===n.length||n.some((function(t){return!t})),s=new Qr(r.level,r.sn,r.stats.chunkCount+1,0,i?i.index:-1,!a);e.flush(s)}},r._handleFragmentLoadProgress=function(t){},r._doFragLoad=function(t,e,r,i){var n,a=this;void 0===r&&(r=null);var s=null==e?void 0:e.details;if(!this.levels||!s)throw new Error("frag load aborted, missing level"+(s?"":" detail")+"s");var o=null;if(!t.encrypted||null!=(n=t.decryptdata)&&n.key?!t.encrypted&&s.encryptedFragments.length&&this.keyLoader.loadClear(t,s.encryptedFragments):(this.log("Loading key for "+t.sn+" of ["+s.startSN+"-"+s.endSN+"], "+("[stream-controller]"===this.logPrefix?"level":"track")+" "+t.level),this.state=gi,this.fragCurrent=t,o=this.keyLoader.load(t).then((function(t){if(!a.fragContextChanged(t.frag))return a.hls.trigger(S.KEY_LOADED,t),a.state===gi&&(a.state=fi),t})),this.hls.trigger(S.KEY_LOADING,{frag:t}),null===this.fragCurrent&&(o=Promise.reject(new Error("frag load aborted, context changed in KEY_LOADING")))),r=Math.max(t.start,r||0),this.config.lowLatencyMode&&"initSegment"!==t.sn){var l=s.partList;if(l&&i){r>t.end&&s.fragmentHint&&(t=s.fragmentHint);var u=this.getNextPart(l,t,r);if(u>-1){var h,d=l[u];return this.log("Loading part sn: "+t.sn+" p: "+d.index+" cc: "+t.cc+" of playlist ["+s.startSN+"-"+s.endSN+"] parts [0-"+u+"-"+(l.length-1)+"] "+("[stream-controller]"===this.logPrefix?"level":"track")+": "+t.level+", target: "+parseFloat(r.toFixed(3))),this.nextLoadPosition=d.start+d.duration,this.state=vi,h=o?o.then((function(r){return!r||a.fragContextChanged(r.frag)?null:a.doFragPartsLoad(t,d,e,i)})).catch((function(t){return a.handleFragLoadError(t)})):this.doFragPartsLoad(t,d,e,i).catch((function(t){return a.handleFragLoadError(t)})),this.hls.trigger(S.FRAG_LOADING,{frag:t,part:d,targetBufferTime:r}),null===this.fragCurrent?Promise.reject(new Error("frag load aborted, context changed in FRAG_LOADING parts")):h}if(!t.url||this.loadedEndOfParts(l,r))return Promise.resolve(null)}}this.log("Loading fragment "+t.sn+" cc: "+t.cc+" "+(s?"of ["+s.startSN+"-"+s.endSN+"] ":"")+("[stream-controller]"===this.logPrefix?"level":"track")+": "+t.level+", target: "+parseFloat(r.toFixed(3))),y(t.sn)&&!this.bitrateTest&&(this.nextLoadPosition=t.start+t.duration),this.state=vi;var c,f=this.config.progressive;return c=f&&o?o.then((function(e){return!e||a.fragContextChanged(null==e?void 0:e.frag)?null:a.fragmentLoader.load(t,i)})).catch((function(t){return a.handleFragLoadError(t)})):Promise.all([this.fragmentLoader.load(t,f?i:void 0),o]).then((function(t){var e=t[0];return!f&&e&&i&&i(e),e})).catch((function(t){return a.handleFragLoadError(t)})),this.hls.trigger(S.FRAG_LOADING,{frag:t,targetBufferTime:r}),null===this.fragCurrent?Promise.reject(new Error("frag load aborted, context changed in FRAG_LOADING")):c},r.doFragPartsLoad=function(t,e,r,i){var n=this;return new Promise((function(a,s){var o,l=[],u=null==(o=r.details)?void 0:o.partList;!function e(o){n.fragmentLoader.loadPart(t,o,i).then((function(i){l[o.index]=i;var s=i.part;n.hls.trigger(S.FRAG_LOADED,i);var h=or(r,t.sn,o.index+1)||lr(u,t.sn,o.index+1);if(!h)return a({frag:t,part:s,partsLoaded:l});e(h)})).catch(s)}(e)}))},r.handleFragLoadError=function(t){if("data"in t){var e=t.data;t.data&&e.details===A.INTERNAL_ABORTED?this.handleFragLoadAborted(e.frag,e.part):this.hls.trigger(S.ERROR,e)}else this.hls.trigger(S.ERROR,{type:L.OTHER_ERROR,details:A.INTERNAL_EXCEPTION,err:t,error:t,fatal:!0});return null},r._handleTransmuxerFlush=function(t){var e=this.getCurrentContext(t);if(e&&this.state===yi){var r=e.frag,i=e.part,n=e.level,a=self.performance.now();r.stats.parsing.end=a,i&&(i.stats.parsing.end=a),this.updateLevelTiming(r,i,n,t.partial)}else this.fragCurrent||this.state===ci||this.state===Si||(this.state=fi)},r.getCurrentContext=function(t){var e=this.levels,r=this.fragCurrent,i=t.level,n=t.sn,a=t.part;if(null==e||!e[i])return this.warn("Levels object was unset while buffering fragment "+n+" of level "+i+". The current chunk will not be buffered."),null;var s=e[i],o=a>-1?or(s,n,a):null,l=o?o.fragment:function(t,e,r){if(null==t||!t.details)return null;var i=t.details,n=i.fragments[e-i.startSN];return n||((n=i.fragmentHint)&&n.sn===e?n:e<i.startSN&&r&&r.sn===e?r:null)}(s,n,r);return l?(r&&r!==l&&(l.stats=r.stats),{frag:l,part:o,level:s}):null},r.bufferFragmentData=function(t,e,r,i,n){var a;if(t&&this.state===yi){var s=t.data1,o=t.data2,l=s;if(s&&o&&(l=Gt(s,o)),null!=(a=l)&&a.length){var u={type:t.type,frag:e,part:r,chunkMeta:i,parent:e.type,data:l};if(this.hls.trigger(S.BUFFER_APPENDING,u),t.dropped&&t.independent&&!r){if(n)return;this.flushBufferGap(e)}}}},r.flushBufferGap=function(t){var e=this.media;if(e)if(zr.isBuffered(e,e.currentTime)){var r=e.currentTime,i=zr.bufferInfo(e,r,0),n=t.duration,a=Math.min(2*this.config.maxFragLookUpTolerance,.25*n),s=Math.max(Math.min(t.start-a,i.end-a),r+a);t.start-s>a&&this.flushMainBuffer(s,t.start)}else this.flushMainBuffer(0,t.start)},r.getFwdBufferInfo=function(t,e){var r=this.getLoadPosition();return y(r)?this.getFwdBufferInfoAtPos(t,r,e):null},r.getFwdBufferInfoAtPos=function(t,e,r){var i=this.config.maxBufferHole,n=zr.bufferInfo(t,e,i);if(0===n.len&&void 0!==n.nextStart){var a=this.fragmentTracker.getBufferedFrag(e,r);if(a&&n.nextStart<a.end)return zr.bufferInfo(t,e,Math.max(n.nextStart,i))}return n},r.getMaxBufferLength=function(t){var e,r=this.config;return e=t?Math.max(8*r.maxBufferSize/t,r.maxBufferLength):r.maxBufferLength,Math.min(e,r.maxMaxBufferLength)},r.reduceMaxBufferLength=function(t){var e=this.config,r=t||e.maxBufferLength;return e.maxMaxBufferLength>=r&&(e.maxMaxBufferLength/=2,this.warn("Reduce max buffer length to "+e.maxMaxBufferLength+"s"),!0)},r.getAppendedFrag=function(t,e){var r=this.fragmentTracker.getAppendedFrag(t,Ie);return r&&"fragment"in r?r.fragment:r},r.getNextFragment=function(t,e){var r=e.fragments,i=r.length;if(!i)return null;var n,a=this.config,s=r[0].start;if(e.live){var o=a.initialLiveManifestSize;if(i<o)return this.warn("Not enough fragments to start playback (have: "+i+", need: "+o+")"),null;(!e.PTSKnown&&!this.startFragRequested&&-1===this.startPosition||t<s)&&(n=this.getInitialLiveFragment(e,r),this.startPosition=this.nextLoadPosition=n?this.hls.liveSyncPosition||n.start:t)}else t<=s&&(n=r[0]);if(!n){var l=a.lowLatencyMode?e.partEnd:e.fragmentEnd;n=this.getFragmentAtPosition(t,l,e)}return this.mapToInitFragWhenRequired(n)},r.isLoopLoading=function(t,e){var r=this.fragmentTracker.getState(t);return(r===Yr||r===Vr&&!!t.gap)&&this.nextLoadPosition>e},r.getNextFragmentLoopLoading=function(t,e,r,i,n){var a=t.gap,s=this.getNextFragment(this.nextLoadPosition,e);if(null===s)return s;if(t=s,a&&t&&!t.gap&&r.nextStart){var o=this.getFwdBufferInfoAtPos(this.mediaBuffer?this.mediaBuffer:this.media,r.nextStart,i);if(null!==o&&r.len+o.len>=n)return this.log('buffer full after gaps in "'+i+'" playlist starting at sn: '+t.sn),null}return t},r.mapToInitFragWhenRequired=function(t){return null==t||!t.initSegment||null!=t&&t.initSegment.data||this.bitrateTest?t:t.initSegment},r.getNextPart=function(t,e,r){for(var i=-1,n=!1,a=!0,s=0,o=t.length;s<o;s++){var l=t[s];if(a=a&&!l.independent,i>-1&&r<l.start)break;var u=l.loaded;u?i=-1:(n||l.independent||a)&&l.fragment===e&&(i=s),n=u}return i},r.loadedEndOfParts=function(t,e){var r=t[t.length-1];return r&&e>r.start&&r.loaded},r.getInitialLiveFragment=function(t,e){var r=this.fragPrevious,i=null;if(r){if(t.hasProgramDateTime&&(this.log("Live playlist, switching playlist, load frag with same PDT: "+r.programDateTime),i=function(t,e,r){if(null===e||!Array.isArray(t)||!t.length||!y(e))return null;if(e<(t[0].programDateTime||0))return null;if(e>=(t[t.length-1].endProgramDateTime||0))return null;r=r||0;for(var i=0;i<t.length;++i){var n=t[i];if(yr(e,r,n))return n}return null}(e,r.endProgramDateTime,this.config.maxFragLookUpTolerance)),!i){var n=r.sn+1;if(n>=t.startSN&&n<=t.endSN){var a=e[n-t.startSN];r.cc===a.cc&&(i=a,this.log("Live playlist, switching playlist, load frag with next SN: "+i.sn))}i||(i=function(t,e){return vr(t,(function(t){return t.cc<e?1:t.cc>e?-1:0}))}(e,r.cc),i&&this.log("Live playlist, switching playlist, load frag with same CC: "+i.sn))}}else{var s=this.hls.liveSyncPosition;null!==s&&(i=this.getFragmentAtPosition(s,this.bitrateTest?t.fragmentEnd:t.edge,t))}return i},r.getFragmentAtPosition=function(t,e,r){var i,n=this.config,a=this.fragPrevious,s=r.fragments,o=r.endSN,l=r.fragmentHint,u=n.maxFragLookUpTolerance,h=r.partList,d=!!(n.lowLatencyMode&&null!=h&&h.length&&l);if(d&&l&&!this.bitrateTest&&(s=s.concat(l),o=l.sn),i=t<e?mr(a,s,t,t>e-u?0:u):s[s.length-1]){var c=i.sn-r.startSN,f=this.fragmentTracker.getState(i);if((f===Yr||f===Vr&&i.gap)&&(a=i),a&&i.sn===a.sn&&(!d||h[0].fragment.sn>i.sn)&&a&&i.level===a.level){var g=s[c+1];i=i.sn<o&&this.fragmentTracker.getState(g)!==Yr?g:null}}return i},r.synchronizeToLiveEdge=function(t){var e=this.config,r=this.media;if(r){var i=this.hls.liveSyncPosition,n=r.currentTime,a=t.fragments[0].start,s=t.edge,o=n>=a-e.maxFragLookUpTolerance&&n<=s;if(null!==i&&r.duration>i&&(n<i||!o)){var l=void 0!==e.liveMaxLatencyDuration?e.liveMaxLatencyDuration:e.liveMaxLatencyDurationCount*t.targetduration;(!o&&r.readyState<4||n<s-l)&&(this.loadedmetadata||(this.nextLoadPosition=i),r.readyState&&(this.warn("Playback: "+n.toFixed(3)+" is located too far from the end of live sliding playlist: "+s+", reset currentTime to : "+i.toFixed(3)),r.currentTime=i))}}},r.alignPlaylists=function(t,e,r){var i=t.fragments.length;if(!i)return this.warn("No fragments in live playlist"),0;var n=t.fragments[0].start,a=!e,s=t.alignedSliding&&y(n);if(a||!s&&!n){var o=this.fragPrevious;ti(o,r,t);var l=t.fragments[0].start;return this.log("Live playlist sliding: "+l.toFixed(2)+" start-sn: "+(e?e.startSN:"na")+"->"+t.startSN+" prev-sn: "+(o?o.sn:"na")+" fragments: "+i),l}return n},r.waitForCdnTuneIn=function(t){return t.live&&t.canBlockReload&&t.partTarget&&t.tuneInGoal>Math.max(t.partHoldBack,3*t.partTarget)},r.setStartPosition=function(t,e){var r=this.startPosition;if(r<e&&(r=-1),-1===r||-1===this.lastCurrentTime){var i=null!==this.startTimeOffset,n=i?this.startTimeOffset:t.startTimeOffset;null!==n&&y(n)?(r=e+n,n<0&&(r+=t.totalduration),r=Math.min(Math.max(e,r),e+t.totalduration),this.log("Start time offset "+n+" found in "+(i?"multivariant":"media")+" playlist, adjust startPosition to "+r),this.startPosition=r):t.live?r=this.hls.liveSyncPosition||e:this.startPosition=r=0,this.lastCurrentTime=r}this.nextLoadPosition=r},r.getLoadPosition=function(){var t=this.media,e=0;return this.loadedmetadata&&t?e=t.currentTime:this.nextLoadPosition&&(e=this.nextLoadPosition),e},r.handleFragLoadAborted=function(t,e){this.transmuxer&&"initSegment"!==t.sn&&t.stats.aborted&&(this.warn("Fragment "+t.sn+(e?" part "+e.index:"")+" of level "+t.level+" was aborted"),this.resetFragmentLoading(t))},r.resetFragmentLoading=function(t){this.fragCurrent&&(this.fragContextChanged(t)||this.state===mi)||(this.state=fi)},r.onFragmentOrKeyLoadError=function(t,e){if(e.chunkMeta&&!e.frag){var r=this.getCurrentContext(e.chunkMeta);r&&(e.frag=r.frag)}var i=e.frag;if(i&&i.type===t&&this.levels)if(this.fragContextChanged(i)){var n;this.warn("Frag load error must match current frag to retry "+i.url+" > "+(null==(n=this.fragCurrent)?void 0:n.url))}else{var a=e.details===A.FRAG_GAP;a&&this.fragmentTracker.fragBuffered(i,!0);var s=e.errorAction,o=s||{},l=o.action,u=o.retryCount,h=void 0===u?0:u,d=o.retryConfig;if(s&&l===Lr&&d){this.resetStartWhenNotLoaded(this.levelLastLoaded);var c=cr(d,h);this.warn("Fragment "+i.sn+" of "+t+" "+i.level+" errored with "+e.details+", retrying loading "+(h+1)+"/"+d.maxNumRetry+" in "+c+"ms"),s.resolved=!0,this.retryDate=self.performance.now()+c,this.state=mi}else if(d&&s){if(this.resetFragmentErrors(t),!(h<d.maxNumRetry))return void w.warn(e.details+" reached or exceeded max retry ("+h+")");a||l===Sr||(s.resolved=!0)}else(null==s?void 0:s.action)===Tr?this.state=Ai:this.state=Si;this.tickImmediate()}},r.reduceLengthAndFlushBuffer=function(t){if(this.state===yi||this.state===Ei){var e=t.parent,r=this.getFwdBufferInfo(this.mediaBuffer,e),i=r&&r.len>.5;i&&this.reduceMaxBufferLength(r.len);var n=!i;return n&&this.warn("Buffer full error while media.currentTime is not buffered, flush "+e+" buffer"),t.frag&&(this.fragmentTracker.removeFragment(t.frag),this.nextLoadPosition=t.frag.start),this.resetLoadingState(),n}return!1},r.resetFragmentErrors=function(t){t===we&&(this.fragCurrent=null),this.loadedmetadata||(this.startFragRequested=!1),this.state!==ci&&(this.state=fi)},r.afterBufferFlushed=function(t,e,r){if(t){var i=zr.getBuffered(t);this.fragmentTracker.detectEvictedFragments(e,i,r),this.state===Ti&&this.resetLoadingState()}},r.resetLoadingState=function(){this.log("Reset loading state"),this.fragCurrent=null,this.fragPrevious=null,this.state=fi},r.resetStartWhenNotLoaded=function(t){if(!this.loadedmetadata){this.startFragRequested=!1;var e=t?t.details:null;null!=e&&e.live?(this.startPosition=-1,this.setStartPosition(e,0),this.resetLoadingState()):this.nextLoadPosition=this.startPosition}},r.resetWhenMissingContext=function(t){this.warn("The loading context changed while buffering fragment "+t.sn+" of level "+t.level+". This chunk will not be buffered."),this.removeUnbufferedFrags(),this.resetStartWhenNotLoaded(this.levelLastLoaded),this.resetLoadingState()},r.removeUnbufferedFrags=function(t){void 0===t&&(t=0),this.fragmentTracker.removeFragmentsInRange(t,1/0,this.playlistType,!1,!0)},r.updateLevelTiming=function(t,e,r,i){var n,a=this,s=r.details;if(s){if(!Object.keys(t.elementaryStreams).reduce((function(e,n){var o=t.elementaryStreams[n];if(o){var l=o.endPTS-o.startPTS;if(l<=0)return a.warn("Could not parse fragment "+t.sn+" "+n+" duration reliably ("+l+")"),e||!1;var u=i?0:ir(s,t,o.startPTS,o.endPTS,o.startDTS,o.endDTS);return a.hls.trigger(S.LEVEL_PTS_UPDATED,{details:s,level:r,drift:u,type:n,frag:t,start:o.startPTS,end:o.endPTS}),!0}return e}),!1)&&null===(null==(n=this.transmuxer)?void 0:n.error)){var o=new Error("Found no media in fragment "+t.sn+" of level "+t.level+" resetting transmuxer to fallback to playlist timing");if(0===r.fragmentError&&(r.fragmentError++,t.gap=!0,this.fragmentTracker.removeFragment(t),this.fragmentTracker.fragBuffered(t,!0)),this.warn(o.message),this.hls.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_PARSING_ERROR,fatal:!1,error:o,frag:t,reason:"Found no media in msn "+t.sn+' of level "'+r.url+'"'}),!this.hls)return;this.resetTransmuxer()}this.state=Ei,this.hls.trigger(S.FRAG_PARSED,{frag:t,part:e})}else this.warn("level.details undefined")},r.resetTransmuxer=function(){this.transmuxer&&(this.transmuxer.destroy(),this.transmuxer=null)},r.recoverWorkerError=function(t){"demuxerWorker"===t.event&&(this.fragmentTracker.removeAllFragments(),this.resetTransmuxer(),this.resetStartWhenNotLoaded(this.levelLastLoaded),this.resetLoadingState())},s(e,[{key:"state",get:function(){return this._state},set:function(t){var e=this._state;e!==t&&(this._state=t,this.log(e+"->"+t))}}]),e}(Gr),ki=function(){function t(){this.chunks=[],this.dataLength=0}var e=t.prototype;return e.push=function(t){this.chunks.push(t),this.dataLength+=t.length},e.flush=function(){var t,e=this.chunks,r=this.dataLength;return e.length?(t=1===e.length?e[0]:function(t,e){for(var r=new Uint8Array(e),i=0,n=0;n<t.length;n++){var a=t[n];r.set(a,i),i+=a.length}return r}(e,r),this.reset(),t):new Uint8Array(0)},e.reset=function(){this.chunks.length=0,this.dataLength=0},t}();function bi(t,e){return void 0===t&&(t=""),void 0===e&&(e=9e4),{type:t,id:-1,pid:-1,inputTimeScale:e,sequenceNumber:-1,samples:[],dropped:0}}var Di=function(){function t(){this._audioTrack=void 0,this._id3Track=void 0,this.frameIndex=0,this.cachedData=null,this.basePTS=null,this.initPTS=null,this.lastPTS=null}var e=t.prototype;return e.resetInitSegment=function(t,e,r,i){this._id3Track={type:"id3",id:3,pid:-1,inputTimeScale:9e4,sequenceNumber:0,samples:[],dropped:0}},e.resetTimeStamp=function(t){this.initPTS=t,this.resetContiguity()},e.resetContiguity=function(){this.basePTS=null,this.lastPTS=null,this.frameIndex=0},e.canParse=function(t,e){return!1},e.appendFrame=function(t,e,r){},e.demux=function(t,e){this.cachedData&&(t=Gt(this.cachedData,t),this.cachedData=null);var r,i=lt(t,0),n=i?i.length:0,a=this._audioTrack,s=this._id3Track,o=i?dt(i):void 0,l=t.length;for((null===this.basePTS||0===this.frameIndex&&y(o))&&(this.basePTS=Ii(o,e,this.initPTS),this.lastPTS=this.basePTS),null===this.lastPTS&&(this.lastPTS=this.basePTS),i&&i.length>0&&s.samples.push({pts:this.lastPTS,dts:this.lastPTS,data:i,type:Be,duration:Number.POSITIVE_INFINITY});n<l;){if(this.canParse(t,n)){var u=this.appendFrame(a,t,n);u?(this.frameIndex++,this.lastPTS=u.sample.pts,r=n+=u.length):n=l}else ht(t,n)?(i=lt(t,n),s.samples.push({pts:this.lastPTS,dts:this.lastPTS,data:i,type:Be,duration:Number.POSITIVE_INFINITY}),r=n+=i.length):n++;if(n===l&&r!==l){var h=nt(t,r);this.cachedData?this.cachedData=Gt(this.cachedData,h):this.cachedData=h}}return{audioTrack:a,videoTrack:bi(),id3Track:s,textTrack:bi()}},e.demuxSampleAes=function(t,e,r){return Promise.reject(new Error("["+this+"] This demuxer does not support Sample-AES decryption"))},e.flush=function(t){var e=this.cachedData;return e&&(this.cachedData=null,this.demux(e,0)),{audioTrack:this._audioTrack,videoTrack:bi(),id3Track:this._id3Track,textTrack:bi()}},e.destroy=function(){},t}(),Ii=function(t,e,r){return y(t)?90*t:9e4*e+(r?9e4*r.baseTime/r.timescale:0)};function wi(t,e){return 255===t[e]&&240==(246&t[e+1])}function Ci(t,e){return 1&t[e+1]?7:9}function _i(t,e){return(3&t[e+3])<<11|t[e+4]<<3|(224&t[e+5])>>>5}function xi(t,e){return e+1<t.length&&wi(t,e)}function Pi(t,e){if(xi(t,e)){var r=Ci(t,e);if(e+r>=t.length)return!1;var i=_i(t,e);if(i<=r)return!1;var n=e+i;return n===t.length||xi(t,n)}return!1}function Fi(t,e,r,i,n){if(!t.samplerate){var a=function(t,e,r,i){var n,a,s,o,l=navigator.userAgent.toLowerCase(),u=i,h=[96e3,88200,64e3,48e3,44100,32e3,24e3,22050,16e3,12e3,11025,8e3,7350];n=1+((192&e[r+2])>>>6);var d=(60&e[r+2])>>>2;if(!(d>h.length-1))return s=(1&e[r+2])<<2,s|=(192&e[r+3])>>>6,w.log("manifest codec:"+i+", ADTS type:"+n+", samplingIndex:"+d),/firefox/i.test(l)?d>=6?(n=5,o=new Array(4),a=d-3):(n=2,o=new Array(2),a=d):-1!==l.indexOf("android")?(n=2,o=new Array(2),a=d):(n=5,o=new Array(4),i&&(-1!==i.indexOf("mp4a.40.29")||-1!==i.indexOf("mp4a.40.5"))||!i&&d>=6?a=d-3:((i&&-1!==i.indexOf("mp4a.40.2")&&(d>=6&&1===s||/vivaldi/i.test(l))||!i&&1===s)&&(n=2,o=new Array(2)),a=d)),o[0]=n<<3,o[0]|=(14&d)>>1,o[1]|=(1&d)<<7,o[1]|=s<<3,5===n&&(o[1]|=(14&a)>>1,o[2]=(1&a)<<7,o[2]|=8,o[3]=0),{config:o,samplerate:h[d],channelCount:s,codec:"mp4a.40."+n,manifestCodec:u};var c=new Error("invalid ADTS sampling index:"+d);t.emit(S.ERROR,S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_PARSING_ERROR,fatal:!0,error:c,reason:c.message})}(e,r,i,n);if(!a)return;t.config=a.config,t.samplerate=a.samplerate,t.channelCount=a.channelCount,t.codec=a.codec,t.manifestCodec=a.manifestCodec,w.log("parsed codec:"+t.codec+", rate:"+a.samplerate+", channels:"+a.channelCount)}}function Mi(t){return 9216e4/t}function Oi(t,e,r,i,n){var a,s=i+n*Mi(t.samplerate),o=function(t,e){var r=Ci(t,e);if(e+r<=t.length){var i=_i(t,e)-r;if(i>0)return{headerLength:r,frameLength:i}}}(e,r);if(o){var l=o.frameLength,u=o.headerLength,h=u+l,d=Math.max(0,r+h-e.length);d?(a=new Uint8Array(h-u)).set(e.subarray(r+u,e.length),0):a=e.subarray(r+u,r+h);var c={unit:a,pts:s};return d||t.samples.push(c),{sample:c,length:h,missing:d}}var f=e.length-r;return(a=new Uint8Array(f)).set(e.subarray(r,e.length),0),{sample:{unit:a,pts:s},length:f,missing:-1}}var Ni=null,Ui=[32,64,96,128,160,192,224,256,288,320,352,384,416,448,32,48,56,64,80,96,112,128,160,192,224,256,320,384,32,40,48,56,64,80,96,112,128,160,192,224,256,320,32,48,56,64,80,96,112,128,144,160,176,192,224,256,8,16,24,32,40,48,56,64,80,96,112,128,144,160],Bi=[44100,48e3,32e3,22050,24e3,16e3,11025,12e3,8e3],Gi=[[0,72,144,12],[0,0,0,0],[0,72,144,12],[0,144,144,12]],Ki=[0,1,1,4];function Hi(t,e,r,i,n){if(!(r+24>e.length)){var a=Vi(e,r);if(a&&r+a.frameLength<=e.length){var s=i+n*(9e4*a.samplesPerFrame/a.sampleRate),o={unit:e.subarray(r,r+a.frameLength),pts:s,dts:s};return t.config=[],t.channelCount=a.channelCount,t.samplerate=a.sampleRate,t.samples.push(o),{sample:o,length:a.frameLength,missing:0}}}}function Vi(t,e){var r=t[e+1]>>3&3,i=t[e+1]>>1&3,n=t[e+2]>>4&15,a=t[e+2]>>2&3;if(1!==r&&0!==n&&15!==n&&3!==a){var s=t[e+2]>>1&1,o=t[e+3]>>6,l=1e3*Ui[14*(3===r?3-i:3===i?3:4)+n-1],u=Bi[3*(3===r?0:2===r?1:2)+a],h=3===o?1:2,d=Gi[r][i],c=Ki[i],f=8*d*c,g=Math.floor(d*l/u+s)*c;if(null===Ni){var v=(navigator.userAgent||"").match(/Chrome\/(\d+)/i);Ni=v?parseInt(v[1]):0}return!!Ni&&Ni<=87&&2===i&&l>=224e3&&0===o&&(t[e+3]=128|t[e+3]),{sampleRate:u,channelCount:h,frameLength:g,samplesPerFrame:f}}}function Yi(t,e){return 255===t[e]&&224==(224&t[e+1])&&0!=(6&t[e+1])}function Wi(t,e){return e+1<t.length&&Yi(t,e)}function ji(t,e){if(e+1<t.length&&Yi(t,e)){var r=Vi(t,e),i=4;null!=r&&r.frameLength&&(i=r.frameLength);var n=e+i;return n===t.length||Wi(t,n)}return!1}var qi=function(t){function e(e,r){var i;return(i=t.call(this)||this).observer=void 0,i.config=void 0,i.observer=e,i.config=r,i}l(e,t);var r=e.prototype;return r.resetInitSegment=function(e,r,i,n){t.prototype.resetInitSegment.call(this,e,r,i,n),this._audioTrack={container:"audio/adts",type:"audio",id:2,pid:-1,sequenceNumber:0,segmentCodec:"aac",samples:[],manifestCodec:r,duration:n,inputTimeScale:9e4,dropped:0}},e.probe=function(t){if(!t)return!1;var e=lt(t,0),r=(null==e?void 0:e.length)||0;if(ji(t,r))return!1;for(var i=t.length;r<i;r++)if(Pi(t,r))return w.log("ADTS sync word found !"),!0;return!1},r.canParse=function(t,e){return function(t,e){return function(t,e){return e+5<t.length}(t,e)&&wi(t,e)&&_i(t,e)<=t.length-e}(t,e)},r.appendFrame=function(t,e,r){Fi(t,this.observer,e,r,t.manifestCodec);var i=Oi(t,e,r,this.basePTS,this.frameIndex);if(i&&0===i.missing)return i},e}(Di),Xi=/\/emsg[-/]ID3/i,zi=function(){function t(t,e){this.remainderData=null,this.timeOffset=0,this.config=void 0,this.videoTrack=void 0,this.audioTrack=void 0,this.id3Track=void 0,this.txtTrack=void 0,this.config=e}var e=t.prototype;return e.resetTimeStamp=function(){},e.resetInitSegment=function(t,e,r,i){var n=this.videoTrack=bi("video",1),a=this.audioTrack=bi("audio",1),s=this.txtTrack=bi("text",1);if(this.id3Track=bi("id3",1),this.timeOffset=0,null!=t&&t.byteLength){var o=Pt(t);if(o.video){var l=o.video,u=l.id,h=l.timescale,d=l.codec;n.id=u,n.timescale=s.timescale=h,n.codec=d}if(o.audio){var c=o.audio,f=c.id,g=c.timescale,v=c.codec;a.id=f,a.timescale=g,a.codec=v}s.id=kt.text,n.sampleDuration=0,n.duration=a.duration=i}},e.resetContiguity=function(){this.remainderData=null},t.probe=function(t){return function(t){for(var e=t.byteLength,r=0;r<e;){var i=It(t,r);if(i>8&&109===t[r+4]&&111===t[r+5]&&111===t[r+6]&&102===t[r+7])return!0;r=i>1?r+i:e}return!1}(t)},e.demux=function(t,e){this.timeOffset=e;var r=t,i=this.videoTrack,n=this.txtTrack;if(this.config.progressive){this.remainderData&&(r=Gt(this.remainderData,t));var a=function(t){var e={valid:null,remainder:null},r=_t(t,["moof"]);if(r.length<2)return e.remainder=t,e;var i=r[r.length-1];return e.valid=nt(t,0,i.byteOffset-8),e.remainder=nt(t,i.byteOffset-8),e}(r);this.remainderData=a.remainder,i.samples=a.valid||new Uint8Array}else i.samples=r;var s=this.extractID3Track(i,e);return n.samples=Kt(e,i),{videoTrack:i,audioTrack:this.audioTrack,id3Track:s,textTrack:this.txtTrack}},e.flush=function(){var t=this.timeOffset,e=this.videoTrack,r=this.txtTrack;e.samples=this.remainderData||new Uint8Array,this.remainderData=null;var i=this.extractID3Track(e,this.timeOffset);return r.samples=Kt(t,e),{videoTrack:e,audioTrack:bi(),id3Track:i,textTrack:bi()}},e.extractID3Track=function(t,e){var r=this.id3Track;if(t.samples.length){var i=_t(t.samples,["emsg"]);i&&i.forEach((function(t){var i=function(t){var e=t[0],r="",i="",n=0,a=0,s=0,o=0,l=0,u=0;if(0===e){for(;"\0"!==bt(t.subarray(u,u+1));)r+=bt(t.subarray(u,u+1)),u+=1;for(r+=bt(t.subarray(u,u+1)),u+=1;"\0"!==bt(t.subarray(u,u+1));)i+=bt(t.subarray(u,u+1)),u+=1;i+=bt(t.subarray(u,u+1)),u+=1,n=It(t,12),a=It(t,16),o=It(t,20),l=It(t,24),u=28}else if(1===e){n=It(t,u+=4);var h=It(t,u+=4),d=It(t,u+=4);for(u+=4,s=Math.pow(2,32)*h+d,E(s)||(s=Number.MAX_SAFE_INTEGER,w.warn("Presentation time exceeds safe integer limit and wrapped to max safe integer in parsing emsg box")),o=It(t,u),l=It(t,u+=4),u+=4;"\0"!==bt(t.subarray(u,u+1));)r+=bt(t.subarray(u,u+1)),u+=1;for(r+=bt(t.subarray(u,u+1)),u+=1;"\0"!==bt(t.subarray(u,u+1));)i+=bt(t.subarray(u,u+1)),u+=1;i+=bt(t.subarray(u,u+1)),u+=1}return{schemeIdUri:r,value:i,timeScale:n,presentationTime:s,presentationTimeDelta:a,eventDuration:o,id:l,payload:t.subarray(u,t.byteLength)}}(t);if(Xi.test(i.schemeIdUri)){var n=y(i.presentationTime)?i.presentationTime/i.timeScale:e+i.presentationTimeDelta/i.timeScale,a=4294967295===i.eventDuration?Number.POSITIVE_INFINITY:i.eventDuration/i.timeScale;a<=.001&&(a=Number.POSITIVE_INFINITY);var s=i.payload;r.samples.push({data:s,len:s.byteLength,dts:n,pts:n,type:Ke,duration:a})}}))}return r},e.demuxSampleAes=function(t,e,r){return Promise.reject(new Error("The MP4 demuxer does not support SAMPLE-AES decryption"))},e.destroy=function(){},t}(),Qi=function(t,e){var r=0,i=5;e+=i;for(var n=new Uint32Array(1),a=new Uint32Array(1),s=new Uint8Array(1);i>0;){s[0]=t[e];var o=Math.min(i,8),l=8-o;a[0]=4278190080>>>24+l<<l,n[0]=(s[0]&a[0])>>l,r=r?r<<o|n[0]:n[0],e+=1,i-=o}return r},Ji=function(t){function e(e){var r;return(r=t.call(this)||this).observer=void 0,r.observer=e,r}l(e,t);var r=e.prototype;return r.resetInitSegment=function(e,r,i,n){t.prototype.resetInitSegment.call(this,e,r,i,n),this._audioTrack={container:"audio/ac-3",type:"audio",id:2,pid:-1,sequenceNumber:0,segmentCodec:"ac3",samples:[],manifestCodec:r,duration:n,inputTimeScale:9e4,dropped:0}},r.canParse=function(t,e){return e+64<t.length},r.appendFrame=function(t,e,r){var i=$i(t,e,r,this.basePTS,this.frameIndex);if(-1!==i)return{sample:t.samples[t.samples.length-1],length:i,missing:0}},e.probe=function(t){if(!t)return!1;var e=lt(t,0);if(!e)return!1;var r=e.length;return 11===t[r]&&119===t[r+1]&&void 0!==dt(e)&&Qi(t,r)<16},e}(Di);function $i(t,e,r,i,n){if(r+8>e.length)return-1;if(11!==e[r]||119!==e[r+1])return-1;var a=e[r+4]>>6;if(a>=3)return-1;var s=[48e3,44100,32e3][a],o=63&e[r+4],l=2*[64,69,96,64,70,96,80,87,120,80,88,120,96,104,144,96,105,144,112,121,168,112,122,168,128,139,192,128,140,192,160,174,240,160,175,240,192,208,288,192,209,288,224,243,336,224,244,336,256,278,384,256,279,384,320,348,480,320,349,480,384,417,576,384,418,576,448,487,672,448,488,672,512,557,768,512,558,768,640,696,960,640,697,960,768,835,1152,768,836,1152,896,975,1344,896,976,1344,1024,1114,1536,1024,1115,1536,1152,1253,1728,1152,1254,1728,1280,1393,1920,1280,1394,1920][3*o+a];if(r+l>e.length)return-1;var u=e[r+6]>>5,h=0;2===u?h+=2:(1&u&&1!==u&&(h+=2),4&u&&(h+=2));var d=(e[r+6]<<8|e[r+7])>>12-h&1,c=[2,1,2,3,3,4,4,5][u]+d,f=e[r+5]>>3,g=7&e[r+5],v=new Uint8Array([a<<6|f<<1|g>>2,(3&g)<<6|u<<3|d<<2|o>>4,o<<4&224]),m=i+n*(1536/s*9e4),p=e.subarray(r,r+l);return t.config=v,t.channelCount=c,t.samplerate=s,t.samples.push({unit:p,pts:m}),l}var Zi=function(){function t(){this.VideoSample=null}var e=t.prototype;return e.createVideoSample=function(t,e,r,i){return{key:t,frame:!1,pts:e,dts:r,units:[],debug:i,length:0}},e.getLastNalUnit=function(t){var e,r,i=this.VideoSample;if(i&&0!==i.units.length||(i=t[t.length-1]),null!=(e=i)&&e.units){var n=i.units;r=n[n.length-1]}return r},e.pushAccessUnit=function(t,e){if(t.units.length&&t.frame){if(void 0===t.pts){var r=e.samples,i=r.length;if(!i)return void e.dropped++;var n=r[i-1];t.pts=n.pts,t.dts=n.dts}e.samples.push(t)}t.debug.length&&w.log(t.pts+"/"+t.dts+":"+t.debug)},t}(),tn=function(){function t(t){this.data=void 0,this.bytesAvailable=void 0,this.word=void 0,this.bitsAvailable=void 0,this.data=t,this.bytesAvailable=t.byteLength,this.word=0,this.bitsAvailable=0}var e=t.prototype;return e.loadWord=function(){var t=this.data,e=this.bytesAvailable,r=t.byteLength-e,i=new Uint8Array(4),n=Math.min(4,e);if(0===n)throw new Error("no bytes available");i.set(t.subarray(r,r+n)),this.word=new DataView(i.buffer).getUint32(0),this.bitsAvailable=8*n,this.bytesAvailable-=n},e.skipBits=function(t){var e;t=Math.min(t,8*this.bytesAvailable+this.bitsAvailable),this.bitsAvailable>t?(this.word<<=t,this.bitsAvailable-=t):(t-=this.bitsAvailable,t-=(e=t>>3)<<3,this.bytesAvailable-=e,this.loadWord(),this.word<<=t,this.bitsAvailable-=t)},e.readBits=function(t){var e=Math.min(this.bitsAvailable,t),r=this.word>>>32-e;if(t>32&&w.error("Cannot read more than 32 bits at a time"),this.bitsAvailable-=e,this.bitsAvailable>0)this.word<<=e;else{if(!(this.bytesAvailable>0))throw new Error("no bits available");this.loadWord()}return(e=t-e)>0&&this.bitsAvailable?r<<e|this.readBits(e):r},e.skipLZ=function(){var t;for(t=0;t<this.bitsAvailable;++t)if(0!=(this.word&2147483648>>>t))return this.word<<=t,this.bitsAvailable-=t,t;return this.loadWord(),t+this.skipLZ()},e.skipUEG=function(){this.skipBits(1+this.skipLZ())},e.skipEG=function(){this.skipBits(1+this.skipLZ())},e.readUEG=function(){var t=this.skipLZ();return this.readBits(t+1)-1},e.readEG=function(){var t=this.readUEG();return 1&t?1+t>>>1:-1*(t>>>1)},e.readBoolean=function(){return 1===this.readBits(1)},e.readUByte=function(){return this.readBits(8)},e.readUShort=function(){return this.readBits(16)},e.readUInt=function(){return this.readBits(32)},e.skipScalingList=function(t){for(var e=8,r=8,i=0;i<t;i++)0!==r&&(r=(e+this.readEG()+256)%256),e=0===r?e:r},e.readSPS=function(){var t,e,r,i=0,n=0,a=0,s=0,o=this.readUByte.bind(this),l=this.readBits.bind(this),u=this.readUEG.bind(this),h=this.readBoolean.bind(this),d=this.skipBits.bind(this),c=this.skipEG.bind(this),f=this.skipUEG.bind(this),g=this.skipScalingList.bind(this);o();var v=o();if(l(5),d(3),o(),f(),100===v||110===v||122===v||244===v||44===v||83===v||86===v||118===v||128===v){var m=u();if(3===m&&d(1),f(),f(),d(1),h())for(e=3!==m?8:12,r=0;r<e;r++)h()&&g(r<6?16:64)}f();var p=u();if(0===p)u();else if(1===p)for(d(1),c(),c(),t=u(),r=0;r<t;r++)c();f(),d(1);var y=u(),E=u(),T=l(1);0===T&&d(1),d(1),h()&&(i=u(),n=u(),a=u(),s=u());var S=[1,1];if(h()&&h())switch(o()){case 1:S=[1,1];break;case 2:S=[12,11];break;case 3:S=[10,11];break;case 4:S=[16,11];break;case 5:S=[40,33];break;case 6:S=[24,11];break;case 7:S=[20,11];break;case 8:S=[32,11];break;case 9:S=[80,33];break;case 10:S=[18,11];break;case 11:S=[15,11];break;case 12:S=[64,33];break;case 13:S=[160,99];break;case 14:S=[4,3];break;case 15:S=[3,2];break;case 16:S=[2,1];break;case 255:S=[o()<<8|o(),o()<<8|o()]}return{width:Math.ceil(16*(y+1)-2*i-2*n),height:(2-T)*(E+1)*16-(T?2:4)*(a+s),pixelRatio:S}},e.readSliceType=function(){return this.readUByte(),this.readUEG(),this.readUEG()},t}(),en=function(t){function e(){return t.apply(this,arguments)||this}l(e,t);var r=e.prototype;return r.parseAVCPES=function(t,e,r,i,n){var a,s=this,o=this.parseAVCNALu(t,r.data),l=this.VideoSample,u=!1;r.data=null,l&&o.length&&!t.audFound&&(this.pushAccessUnit(l,t),l=this.VideoSample=this.createVideoSample(!1,r.pts,r.dts,"")),o.forEach((function(i){var o;switch(i.type){case 1:var h=!1;a=!0;var d,c=i.data;if(u&&c.length>4){var f=new tn(c).readSliceType();2!==f&&4!==f&&7!==f&&9!==f||(h=!0)}h&&null!=(d=l)&&d.frame&&!l.key&&(s.pushAccessUnit(l,t),l=s.VideoSample=null),l||(l=s.VideoSample=s.createVideoSample(!0,r.pts,r.dts,"")),l.frame=!0,l.key=h;break;case 5:a=!0,null!=(o=l)&&o.frame&&!l.key&&(s.pushAccessUnit(l,t),l=s.VideoSample=null),l||(l=s.VideoSample=s.createVideoSample(!0,r.pts,r.dts,"")),l.key=!0,l.frame=!0;break;case 6:a=!0,Vt(i.data,1,r.pts,e.samples);break;case 7:var g,v;a=!0,u=!0;var m=i.data,p=new tn(m).readSPS();if(!t.sps||t.width!==p.width||t.height!==p.height||(null==(g=t.pixelRatio)?void 0:g[0])!==p.pixelRatio[0]||(null==(v=t.pixelRatio)?void 0:v[1])!==p.pixelRatio[1]){t.width=p.width,t.height=p.height,t.pixelRatio=p.pixelRatio,t.sps=[m],t.duration=n;for(var y=m.subarray(1,4),E="avc1.",T=0;T<3;T++){var S=y[T].toString(16);S.length<2&&(S="0"+S),E+=S}t.codec=E}break;case 8:a=!0,t.pps=[i.data];break;case 9:a=!0,t.audFound=!0,l&&s.pushAccessUnit(l,t),l=s.VideoSample=s.createVideoSample(!1,r.pts,r.dts,"");break;case 12:a=!0;break;default:a=!1,l&&(l.debug+="unknown NAL "+i.type+" ")}l&&a&&l.units.push(i)})),i&&l&&(this.pushAccessUnit(l,t),this.VideoSample=null)},r.parseAVCNALu=function(t,e){var r,i,n=e.byteLength,a=t.naluState||0,s=a,o=[],l=0,u=-1,h=0;for(-1===a&&(u=0,h=31&e[0],a=0,l=1);l<n;)if(r=e[l++],a)if(1!==a)if(r)if(1===r){if(i=l-a-1,u>=0){var d={data:e.subarray(u,i),type:h};o.push(d)}else{var c=this.getLastNalUnit(t.samples);c&&(s&&l<=4-s&&c.state&&(c.data=c.data.subarray(0,c.data.byteLength-s)),i>0&&(c.data=Gt(c.data,e.subarray(0,i)),c.state=0))}l<n?(u=l,h=31&e[l],a=0):a=-1}else a=0;else a=3;else a=r?0:2;else a=r?0:1;if(u>=0&&a>=0){var f={data:e.subarray(u,n),type:h,state:a};o.push(f)}if(0===o.length){var g=this.getLastNalUnit(t.samples);g&&(g.data=Gt(g.data,e))}return t.naluState=a,o},e}(Zi),rn=function(){function t(t,e,r){this.keyData=void 0,this.decrypter=void 0,this.keyData=r,this.decrypter=new hi(e,{removePKCS7Padding:!1})}var e=t.prototype;return e.decryptBuffer=function(t){return this.decrypter.decrypt(t,this.keyData.key.buffer,this.keyData.iv.buffer)},e.decryptAacSample=function(t,e,r){var i=this,n=t[e].unit;if(!(n.length<=16)){var a=n.subarray(16,n.length-n.length%16),s=a.buffer.slice(a.byteOffset,a.byteOffset+a.length);this.decryptBuffer(s).then((function(a){var s=new Uint8Array(a);n.set(s,16),i.decrypter.isSync()||i.decryptAacSamples(t,e+1,r)}))}},e.decryptAacSamples=function(t,e,r){for(;;e++){if(e>=t.length)return void r();if(!(t[e].unit.length<32||(this.decryptAacSample(t,e,r),this.decrypter.isSync())))return}},e.getAvcEncryptedData=function(t){for(var e=16*Math.floor((t.length-48)/160)+16,r=new Int8Array(e),i=0,n=32;n<t.length-16;n+=160,i+=16)r.set(t.subarray(n,n+16),i);return r},e.getAvcDecryptedUnit=function(t,e){for(var r=new Uint8Array(e),i=0,n=32;n<t.length-16;n+=160,i+=16)t.set(r.subarray(i,i+16),n);return t},e.decryptAvcSample=function(t,e,r,i,n){var a=this,s=Yt(n.data),o=this.getAvcEncryptedData(s);this.decryptBuffer(o.buffer).then((function(o){n.data=a.getAvcDecryptedUnit(s,o),a.decrypter.isSync()||a.decryptAvcSamples(t,e,r+1,i)}))},e.decryptAvcSamples=function(t,e,r,i){if(t instanceof Uint8Array)throw new Error("Cannot decrypt samples of type Uint8Array");for(;;e++,r=0){if(e>=t.length)return void i();for(var n=t[e].units;!(r>=n.length);r++){var a=n[r];if(!(a.data.length<=48||1!==a.type&&5!==a.type||(this.decryptAvcSample(t,e,r,i,a),this.decrypter.isSync())))return}}},t}(),nn=188,an=function(){function t(t,e,r){this.observer=void 0,this.config=void 0,this.typeSupported=void 0,this.sampleAes=null,this.pmtParsed=!1,this.audioCodec=void 0,this.videoCodec=void 0,this._duration=0,this._pmtId=-1,this._videoTrack=void 0,this._audioTrack=void 0,this._id3Track=void 0,this._txtTrack=void 0,this.aacOverFlow=null,this.remainderData=null,this.videoParser=void 0,this.observer=t,this.config=e,this.typeSupported=r,this.videoParser=new en}t.probe=function(e){var r=t.syncOffset(e);return r>0&&w.warn("MPEG2-TS detected but first sync word found @ offset "+r),-1!==r},t.syncOffset=function(t){for(var e=t.length,r=Math.min(940,e-nn)+1,i=0;i<r;){for(var n=!1,a=-1,s=0,o=i;o<e;o+=nn){if(71!==t[o]||e-o!==nn&&71!==t[o+nn]){if(s)return-1;break}if(s++,-1===a&&0!==(a=o)&&(r=Math.min(a+18612,t.length-nn)+1),n||(n=0===sn(t,o)),n&&s>1&&(0===a&&s>2||o+nn>r))return a}i++}return-1},t.createTrack=function(t,e){return{container:"video"===t||"audio"===t?"video/mp2t":void 0,type:t,id:kt[t],pid:-1,inputTimeScale:9e4,sequenceNumber:0,samples:[],dropped:0,duration:"audio"===t?e:void 0}};var e=t.prototype;return e.resetInitSegment=function(e,r,i,n){this.pmtParsed=!1,this._pmtId=-1,this._videoTrack=t.createTrack("video"),this._audioTrack=t.createTrack("audio",n),this._id3Track=t.createTrack("id3"),this._txtTrack=t.createTrack("text"),this._audioTrack.segmentCodec="aac",this.aacOverFlow=null,this.remainderData=null,this.audioCodec=r,this.videoCodec=i,this._duration=n},e.resetTimeStamp=function(){},e.resetContiguity=function(){var t=this._audioTrack,e=this._videoTrack,r=this._id3Track;t&&(t.pesData=null),e&&(e.pesData=null),r&&(r.pesData=null),this.aacOverFlow=null,this.remainderData=null},e.demux=function(e,r,i,n){var a;void 0===i&&(i=!1),void 0===n&&(n=!1),i||(this.sampleAes=null);var s=this._videoTrack,o=this._audioTrack,l=this._id3Track,u=this._txtTrack,h=s.pid,d=s.pesData,c=o.pid,f=l.pid,g=o.pesData,v=l.pesData,m=null,p=this.pmtParsed,y=this._pmtId,E=e.length;if(this.remainderData&&(E=(e=Gt(this.remainderData,e)).length,this.remainderData=null),E<nn&&!n)return this.remainderData=e,{audioTrack:o,videoTrack:s,id3Track:l,textTrack:u};var T=Math.max(0,t.syncOffset(e));(E-=(E-T)%nn)<e.byteLength&&!n&&(this.remainderData=new Uint8Array(e.buffer,E,e.buffer.byteLength-E));for(var R=0,k=T;k<E;k+=nn)if(71===e[k]){var b=!!(64&e[k+1]),D=sn(e,k),I=void 0;if((48&e[k+3])>>4>1){if((I=k+5+e[k+4])===k+nn)continue}else I=k+4;switch(D){case h:b&&(d&&(a=hn(d))&&this.videoParser.parseAVCPES(s,u,a,!1,this._duration),d={data:[],size:0}),d&&(d.data.push(e.subarray(I,k+nn)),d.size+=k+nn-I);break;case c:if(b){if(g&&(a=hn(g)))switch(o.segmentCodec){case"aac":this.parseAACPES(o,a);break;case"mp3":this.parseMPEGPES(o,a);break;case"ac3":this.parseAC3PES(o,a)}g={data:[],size:0}}g&&(g.data.push(e.subarray(I,k+nn)),g.size+=k+nn-I);break;case f:b&&(v&&(a=hn(v))&&this.parseID3PES(l,a),v={data:[],size:0}),v&&(v.data.push(e.subarray(I,k+nn)),v.size+=k+nn-I);break;case 0:b&&(I+=e[I]+1),y=this._pmtId=on(e,I);break;case y:b&&(I+=e[I]+1);var C=ln(e,I,this.typeSupported,i);(h=C.videoPid)>0&&(s.pid=h,s.segmentCodec=C.segmentVideoCodec),(c=C.audioPid)>0&&(o.pid=c,o.segmentCodec=C.segmentAudioCodec),(f=C.id3Pid)>0&&(l.pid=f),null===m||p||(w.warn("MPEG-TS PMT found at "+k+" after unknown PID '"+m+"'. Backtracking to sync byte @"+T+" to parse all TS packets."),m=null,k=T-188),p=this.pmtParsed=!0;break;case 17:case 8191:break;default:m=D}}else R++;if(R>0){var _=new Error("Found "+R+" TS packet/s that do not start with 0x47");this.observer.emit(S.ERROR,S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_PARSING_ERROR,fatal:!1,error:_,reason:_.message})}s.pesData=d,o.pesData=g,l.pesData=v;var x={audioTrack:o,videoTrack:s,id3Track:l,textTrack:u};return n&&this.extractRemainingSamples(x),x},e.flush=function(){var t,e=this.remainderData;return this.remainderData=null,t=e?this.demux(e,-1,!1,!0):{videoTrack:this._videoTrack,audioTrack:this._audioTrack,id3Track:this._id3Track,textTrack:this._txtTrack},this.extractRemainingSamples(t),this.sampleAes?this.decrypt(t,this.sampleAes):t},e.extractRemainingSamples=function(t){var e,r=t.audioTrack,i=t.videoTrack,n=t.id3Track,a=t.textTrack,s=i.pesData,o=r.pesData,l=n.pesData;if(s&&(e=hn(s))?(this.videoParser.parseAVCPES(i,a,e,!0,this._duration),i.pesData=null):i.pesData=s,o&&(e=hn(o))){switch(r.segmentCodec){case"aac":this.parseAACPES(r,e);break;case"mp3":this.parseMPEGPES(r,e);break;case"ac3":this.parseAC3PES(r,e)}r.pesData=null}else null!=o&&o.size&&w.log("last AAC PES packet truncated,might overlap between fragments"),r.pesData=o;l&&(e=hn(l))?(this.parseID3PES(n,e),n.pesData=null):n.pesData=l},e.demuxSampleAes=function(t,e,r){var i=this.demux(t,r,!0,!this.config.progressive),n=this.sampleAes=new rn(this.observer,this.config,e);return this.decrypt(i,n)},e.decrypt=function(t,e){return new Promise((function(r){var i=t.audioTrack,n=t.videoTrack;i.samples&&"aac"===i.segmentCodec?e.decryptAacSamples(i.samples,0,(function(){n.samples?e.decryptAvcSamples(n.samples,0,0,(function(){r(t)})):r(t)})):n.samples&&e.decryptAvcSamples(n.samples,0,0,(function(){r(t)}))}))},e.destroy=function(){this._duration=0},e.parseAACPES=function(t,e){var r,i,n,a=0,s=this.aacOverFlow,o=e.data;if(s){this.aacOverFlow=null;var l=s.missing,u=s.sample.unit.byteLength;if(-1===l)o=Gt(s.sample.unit,o);else{var h=u-l;s.sample.unit.set(o.subarray(0,l),h),t.samples.push(s.sample),a=s.missing}}for(r=a,i=o.length;r<i-1&&!xi(o,r);r++);if(r!==a){var d,c=r<i-1;d=c?"AAC PES did not start with ADTS header,offset:"+r:"No ADTS header found in AAC PES";var f=new Error(d);if(w.warn("parsing error: "+d),this.observer.emit(S.ERROR,S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_PARSING_ERROR,fatal:!1,levelRetry:c,error:f,reason:d}),!c)return}if(Fi(t,this.observer,o,r,this.audioCodec),void 0!==e.pts)n=e.pts;else{if(!s)return void w.warn("[tsdemuxer]: AAC PES unknown PTS");var g=Mi(t.samplerate);n=s.sample.pts+g}for(var v,m=0;r<i;){if(r+=(v=Oi(t,o,r,n,m)).length,v.missing){this.aacOverFlow=v;break}for(m++;r<i-1&&!xi(o,r);r++);}},e.parseMPEGPES=function(t,e){var r=e.data,i=r.length,n=0,a=0,s=e.pts;if(void 0!==s)for(;a<i;)if(Wi(r,a)){var o=Hi(t,r,a,s,n);if(!o)break;a+=o.length,n++}else a++;else w.warn("[tsdemuxer]: MPEG PES unknown PTS")},e.parseAC3PES=function(t,e){var r=e.data,i=e.pts;if(void 0!==i)for(var n,a=r.length,s=0,o=0;o<a&&(n=$i(t,r,o,i,s++))>0;)o+=n;else w.warn("[tsdemuxer]: AC3 PES unknown PTS")},e.parseID3PES=function(t,e){if(void 0!==e.pts){var r=o({},e,{type:this._videoTrack?Ke:Be,duration:Number.POSITIVE_INFINITY});t.samples.push(r)}else w.warn("[tsdemuxer]: ID3 PES unknown PTS")},t}();function sn(t,e){return((31&t[e+1])<<8)+t[e+2]}function on(t,e){return(31&t[e+10])<<8|t[e+11]}function ln(t,e,r,i){var n={audioPid:-1,videoPid:-1,id3Pid:-1,segmentVideoCodec:"avc",segmentAudioCodec:"aac"},a=e+3+((15&t[e+1])<<8|t[e+2])-4;for(e+=12+((15&t[e+10])<<8|t[e+11]);e<a;){var s=sn(t,e),o=(15&t[e+3])<<8|t[e+4];switch(t[e]){case 207:if(!i){un("ADTS AAC");break}case 15:-1===n.audioPid&&(n.audioPid=s);break;case 21:-1===n.id3Pid&&(n.id3Pid=s);break;case 219:if(!i){un("H.264");break}case 27:-1===n.videoPid&&(n.videoPid=s,n.segmentVideoCodec="avc");break;case 3:case 4:r.mpeg||r.mp3?-1===n.audioPid&&(n.audioPid=s,n.segmentAudioCodec="mp3"):w.log("MPEG audio found, not supported in this browser");break;case 193:if(!i){un("AC-3");break}case 129:r.ac3?-1===n.audioPid&&(n.audioPid=s,n.segmentAudioCodec="ac3"):w.log("AC-3 audio found, not supported in this browser");break;case 6:if(-1===n.audioPid&&o>0)for(var l=e+5,u=o;u>2;){106===t[l]&&(!0!==r.ac3?w.log("AC-3 audio found, not supported in this browser for now"):(n.audioPid=s,n.segmentAudioCodec="ac3"));var h=t[l+1]+2;l+=h,u-=h}break;case 194:case 135:w.warn("Unsupported EC-3 in M2TS found");break;case 36:w.warn("Unsupported HEVC in M2TS found")}e+=o+5}return n}function un(t){w.log(t+" with AES-128-CBC encryption found in unencrypted stream")}function hn(t){var e,r,i,n,a,s=0,o=t.data;if(!t||0===t.size)return null;for(;o[0].length<19&&o.length>1;)o[0]=Gt(o[0],o[1]),o.splice(1,1);if(1===((e=o[0])[0]<<16)+(e[1]<<8)+e[2]){if((r=(e[4]<<8)+e[5])&&r>t.size-6)return null;var l=e[7];192&l&&(n=536870912*(14&e[9])+4194304*(255&e[10])+16384*(254&e[11])+128*(255&e[12])+(254&e[13])/2,64&l?n-(a=536870912*(14&e[14])+4194304*(255&e[15])+16384*(254&e[16])+128*(255&e[17])+(254&e[18])/2)>54e5&&(w.warn(Math.round((n-a)/9e4)+"s delta between PTS and DTS, align them"),n=a):a=n);var u=(i=e[8])+9;if(t.size<=u)return null;t.size-=u;for(var h=new Uint8Array(t.size),d=0,c=o.length;d<c;d++){var f=(e=o[d]).byteLength;if(u){if(u>f){u-=f;continue}e=e.subarray(u),f-=u,u=0}h.set(e,s),s+=f}return r&&(r-=i+3),{data:h,pts:n,dts:a,len:r}}return null}var dn=function(t){function e(){return t.apply(this,arguments)||this}l(e,t);var r=e.prototype;return r.resetInitSegment=function(e,r,i,n){t.prototype.resetInitSegment.call(this,e,r,i,n),this._audioTrack={container:"audio/mpeg",type:"audio",id:2,pid:-1,sequenceNumber:0,segmentCodec:"mp3",samples:[],manifestCodec:r,duration:n,inputTimeScale:9e4,dropped:0}},e.probe=function(t){if(!t)return!1;var e=lt(t,0),r=(null==e?void 0:e.length)||0;if(e&&11===t[r]&&119===t[r+1]&&void 0!==dt(e)&&Qi(t,r)<=16)return!1;for(var i=t.length;r<i;r++)if(ji(t,r))return w.log("MPEG Audio sync word found !"),!0;return!1},r.canParse=function(t,e){return function(t,e){return Yi(t,e)&&4<=t.length-e}(t,e)},r.appendFrame=function(t,e,r){if(null!==this.basePTS)return Hi(t,e,r,this.basePTS,this.frameIndex)},e}(Di),cn=function(){function t(){}return t.getSilentFrame=function(t,e){if("mp4a.40.2"===t){if(1===e)return new Uint8Array([0,200,0,128,35,128]);if(2===e)return new Uint8Array([33,0,73,144,2,25,0,35,128]);if(3===e)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,142]);if(4===e)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,128,44,128,8,2,56]);if(5===e)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,130,48,4,153,0,33,144,2,56]);if(6===e)return new Uint8Array([0,200,0,128,32,132,1,38,64,8,100,0,130,48,4,153,0,33,144,2,0,178,0,32,8,224])}else{if(1===e)return new Uint8Array([1,64,34,128,163,78,230,128,186,8,0,0,0,28,6,241,193,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94]);if(2===e)return new Uint8Array([1,64,34,128,163,94,230,128,186,8,0,0,0,0,149,0,6,241,161,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94]);if(3===e)return new Uint8Array([1,64,34,128,163,94,230,128,186,8,0,0,0,0,149,0,6,241,161,10,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,90,94])}},t}(),fn=Math.pow(2,32)-1,gn=function(){function t(){}return t.init=function(){var e;for(e in t.types={avc1:[],avcC:[],btrt:[],dinf:[],dref:[],esds:[],ftyp:[],hdlr:[],mdat:[],mdhd:[],mdia:[],mfhd:[],minf:[],moof:[],moov:[],mp4a:[],".mp3":[],dac3:[],"ac-3":[],mvex:[],mvhd:[],pasp:[],sdtp:[],stbl:[],stco:[],stsc:[],stsd:[],stsz:[],stts:[],tfdt:[],tfhd:[],traf:[],trak:[],trun:[],trex:[],tkhd:[],vmhd:[],smhd:[]},t.types)t.types.hasOwnProperty(e)&&(t.types[e]=[e.charCodeAt(0),e.charCodeAt(1),e.charCodeAt(2),e.charCodeAt(3)]);var r=new Uint8Array([0,0,0,0,0,0,0,0,118,105,100,101,0,0,0,0,0,0,0,0,0,0,0,0,86,105,100,101,111,72,97,110,100,108,101,114,0]),i=new Uint8Array([0,0,0,0,0,0,0,0,115,111,117,110,0,0,0,0,0,0,0,0,0,0,0,0,83,111,117,110,100,72,97,110,100,108,101,114,0]);t.HDLR_TYPES={video:r,audio:i};var n=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,12,117,114,108,32,0,0,0,1]),a=new Uint8Array([0,0,0,0,0,0,0,0]);t.STTS=t.STSC=t.STCO=a,t.STSZ=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0]),t.VMHD=new Uint8Array([0,0,0,1,0,0,0,0,0,0,0,0]),t.SMHD=new Uint8Array([0,0,0,0,0,0,0,0]),t.STSD=new Uint8Array([0,0,0,0,0,0,0,1]);var s=new Uint8Array([105,115,111,109]),o=new Uint8Array([97,118,99,49]),l=new Uint8Array([0,0,0,1]);t.FTYP=t.box(t.types.ftyp,s,l,s,o),t.DINF=t.box(t.types.dinf,t.box(t.types.dref,n))},t.box=function(t){for(var e=8,r=arguments.length,i=new Array(r>1?r-1:0),n=1;n<r;n++)i[n-1]=arguments[n];for(var a=i.length,s=a;a--;)e+=i[a].byteLength;var o=new Uint8Array(e);for(o[0]=e>>24&255,o[1]=e>>16&255,o[2]=e>>8&255,o[3]=255&e,o.set(t,4),a=0,e=8;a<s;a++)o.set(i[a],e),e+=i[a].byteLength;return o},t.hdlr=function(e){return t.box(t.types.hdlr,t.HDLR_TYPES[e])},t.mdat=function(e){return t.box(t.types.mdat,e)},t.mdhd=function(e,r){r*=e;var i=Math.floor(r/(fn+1)),n=Math.floor(r%(fn+1));return t.box(t.types.mdhd,new Uint8Array([1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,3,e>>24&255,e>>16&255,e>>8&255,255&e,i>>24,i>>16&255,i>>8&255,255&i,n>>24,n>>16&255,n>>8&255,255&n,85,196,0,0]))},t.mdia=function(e){return t.box(t.types.mdia,t.mdhd(e.timescale,e.duration),t.hdlr(e.type),t.minf(e))},t.mfhd=function(e){return t.box(t.types.mfhd,new Uint8Array([0,0,0,0,e>>24,e>>16&255,e>>8&255,255&e]))},t.minf=function(e){return"audio"===e.type?t.box(t.types.minf,t.box(t.types.smhd,t.SMHD),t.DINF,t.stbl(e)):t.box(t.types.minf,t.box(t.types.vmhd,t.VMHD),t.DINF,t.stbl(e))},t.moof=function(e,r,i){return t.box(t.types.moof,t.mfhd(e),t.traf(i,r))},t.moov=function(e){for(var r=e.length,i=[];r--;)i[r]=t.trak(e[r]);return t.box.apply(null,[t.types.moov,t.mvhd(e[0].timescale,e[0].duration)].concat(i).concat(t.mvex(e)))},t.mvex=function(e){for(var r=e.length,i=[];r--;)i[r]=t.trex(e[r]);return t.box.apply(null,[t.types.mvex].concat(i))},t.mvhd=function(e,r){r*=e;var i=Math.floor(r/(fn+1)),n=Math.floor(r%(fn+1)),a=new Uint8Array([1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,3,e>>24&255,e>>16&255,e>>8&255,255&e,i>>24,i>>16&255,i>>8&255,255&i,n>>24,n>>16&255,n>>8&255,255&n,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255]);return t.box(t.types.mvhd,a)},t.sdtp=function(e){var r,i,n=e.samples||[],a=new Uint8Array(4+n.length);for(r=0;r<n.length;r++)i=n[r].flags,a[r+4]=i.dependsOn<<4|i.isDependedOn<<2|i.hasRedundancy;return t.box(t.types.sdtp,a)},t.stbl=function(e){return t.box(t.types.stbl,t.stsd(e),t.box(t.types.stts,t.STTS),t.box(t.types.stsc,t.STSC),t.box(t.types.stsz,t.STSZ),t.box(t.types.stco,t.STCO))},t.avc1=function(e){var r,i,n,a=[],s=[];for(r=0;r<e.sps.length;r++)n=(i=e.sps[r]).byteLength,a.push(n>>>8&255),a.push(255&n),a=a.concat(Array.prototype.slice.call(i));for(r=0;r<e.pps.length;r++)n=(i=e.pps[r]).byteLength,s.push(n>>>8&255),s.push(255&n),s=s.concat(Array.prototype.slice.call(i));var o=t.box(t.types.avcC,new Uint8Array([1,a[3],a[4],a[5],255,224|e.sps.length].concat(a).concat([e.pps.length]).concat(s))),l=e.width,u=e.height,h=e.pixelRatio[0],d=e.pixelRatio[1];return t.box(t.types.avc1,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,l>>8&255,255&l,u>>8&255,255&u,0,72,0,0,0,72,0,0,0,0,0,0,0,1,18,100,97,105,108,121,109,111,116,105,111,110,47,104,108,115,46,106,115,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,17,17]),o,t.box(t.types.btrt,new Uint8Array([0,28,156,128,0,45,198,192,0,45,198,192])),t.box(t.types.pasp,new Uint8Array([h>>24,h>>16&255,h>>8&255,255&h,d>>24,d>>16&255,d>>8&255,255&d])))},t.esds=function(t){var e=t.config.length;return new Uint8Array([0,0,0,0,3,23+e,0,1,0,4,15+e,64,21,0,0,0,0,0,0,0,0,0,0,0,5].concat([e]).concat(t.config).concat([6,1,2]))},t.audioStsd=function(t){var e=t.samplerate;return new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,t.channelCount,0,16,0,0,0,0,e>>8&255,255&e,0,0])},t.mp4a=function(e){return t.box(t.types.mp4a,t.audioStsd(e),t.box(t.types.esds,t.esds(e)))},t.mp3=function(e){return t.box(t.types[".mp3"],t.audioStsd(e))},t.ac3=function(e){return t.box(t.types["ac-3"],t.audioStsd(e),t.box(t.types.dac3,e.config))},t.stsd=function(e){return"audio"===e.type?"mp3"===e.segmentCodec&&"mp3"===e.codec?t.box(t.types.stsd,t.STSD,t.mp3(e)):"ac3"===e.segmentCodec?t.box(t.types.stsd,t.STSD,t.ac3(e)):t.box(t.types.stsd,t.STSD,t.mp4a(e)):t.box(t.types.stsd,t.STSD,t.avc1(e))},t.tkhd=function(e){var r=e.id,i=e.duration*e.timescale,n=e.width,a=e.height,s=Math.floor(i/(fn+1)),o=Math.floor(i%(fn+1));return t.box(t.types.tkhd,new Uint8Array([1,0,0,7,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,3,r>>24&255,r>>16&255,r>>8&255,255&r,0,0,0,0,s>>24,s>>16&255,s>>8&255,255&s,o>>24,o>>16&255,o>>8&255,255&o,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,n>>8&255,255&n,0,0,a>>8&255,255&a,0,0]))},t.traf=function(e,r){var i=t.sdtp(e),n=e.id,a=Math.floor(r/(fn+1)),s=Math.floor(r%(fn+1));return t.box(t.types.traf,t.box(t.types.tfhd,new Uint8Array([0,0,0,0,n>>24,n>>16&255,n>>8&255,255&n])),t.box(t.types.tfdt,new Uint8Array([1,0,0,0,a>>24,a>>16&255,a>>8&255,255&a,s>>24,s>>16&255,s>>8&255,255&s])),t.trun(e,i.length+16+20+8+16+8+8),i)},t.trak=function(e){return e.duration=e.duration||4294967295,t.box(t.types.trak,t.tkhd(e),t.mdia(e))},t.trex=function(e){var r=e.id;return t.box(t.types.trex,new Uint8Array([0,0,0,0,r>>24,r>>16&255,r>>8&255,255&r,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,1]))},t.trun=function(e,r){var i,n,a,s,o,l,u=e.samples||[],h=u.length,d=12+16*h,c=new Uint8Array(d);for(r+=8+d,c.set(["video"===e.type?1:0,0,15,1,h>>>24&255,h>>>16&255,h>>>8&255,255&h,r>>>24&255,r>>>16&255,r>>>8&255,255&r],0),i=0;i<h;i++)a=(n=u[i]).duration,s=n.size,o=n.flags,l=n.cts,c.set([a>>>24&255,a>>>16&255,a>>>8&255,255&a,s>>>24&255,s>>>16&255,s>>>8&255,255&s,o.isLeading<<2|o.dependsOn,o.isDependedOn<<6|o.hasRedundancy<<4|o.paddingValue<<1|o.isNonSync,61440&o.degradPrio,15&o.degradPrio,l>>>24&255,l>>>16&255,l>>>8&255,255&l],12+16*i);return t.box(t.types.trun,c)},t.initSegment=function(e){t.types||t.init();var r=t.moov(e);return Gt(t.FTYP,r)},t}();gn.types=void 0,gn.HDLR_TYPES=void 0,gn.STTS=void 0,gn.STSC=void 0,gn.STCO=void 0,gn.STSZ=void 0,gn.VMHD=void 0,gn.SMHD=void 0,gn.STSD=void 0,gn.FTYP=void 0,gn.DINF=void 0;var vn=9e4;function mn(t,e,r,i){void 0===r&&(r=1),void 0===i&&(i=!1);var n=t*e*r;return i?Math.round(n):n}function pn(t,e){return void 0===e&&(e=!1),mn(t,1e3,1/vn,e)}var yn=null,En=null,Tn=function(){function t(t,e,r,i){if(this.observer=void 0,this.config=void 0,this.typeSupported=void 0,this.ISGenerated=!1,this._initPTS=null,this._initDTS=null,this.nextAvcDts=null,this.nextAudioPts=null,this.videoSampleDuration=null,this.isAudioContiguous=!1,this.isVideoContiguous=!1,this.videoTrackConfig=void 0,this.observer=t,this.config=e,this.typeSupported=r,this.ISGenerated=!1,null===yn){var n=(navigator.userAgent||"").match(/Chrome\/(\d+)/i);yn=n?parseInt(n[1]):0}if(null===En){var a=navigator.userAgent.match(/Safari\/(\d+)/i);En=a?parseInt(a[1]):0}}var e=t.prototype;return e.destroy=function(){this.config=this.videoTrackConfig=this._initPTS=this._initDTS=null},e.resetTimeStamp=function(t){w.log("[mp4-remuxer]: initPTS & initDTS reset"),this._initPTS=this._initDTS=t},e.resetNextTimestamp=function(){w.log("[mp4-remuxer]: reset next timestamp"),this.isVideoContiguous=!1,this.isAudioContiguous=!1},e.resetInitSegment=function(){w.log("[mp4-remuxer]: ISGenerated flag reset"),this.ISGenerated=!1,this.videoTrackConfig=void 0},e.getVideoStartPts=function(t){var e=!1,r=t.reduce((function(t,r){var i=r.pts-t;return i<-4294967296?(e=!0,Sn(t,r.pts)):i>0?t:r.pts}),t[0].pts);return e&&w.debug("PTS rollover detected"),r},e.remux=function(t,e,r,i,n,a,s,o){var l,u,h,d,c,f,g=n,v=n,m=t.pid>-1,p=e.pid>-1,y=e.samples.length,E=t.samples.length>0,T=s&&y>0||y>1;if((!m||E)&&(!p||T)||this.ISGenerated||s){if(this.ISGenerated){var S,L,A,R,k=this.videoTrackConfig;!k||e.width===k.width&&e.height===k.height&&(null==(S=e.pixelRatio)?void 0:S[0])===(null==(L=k.pixelRatio)?void 0:L[0])&&(null==(A=e.pixelRatio)?void 0:A[1])===(null==(R=k.pixelRatio)?void 0:R[1])||this.resetInitSegment()}else h=this.generateIS(t,e,n,a);var b,D=this.isVideoContiguous,I=-1;if(T&&(I=function(t){for(var e=0;e<t.length;e++)if(t[e].key)return e;return-1}(e.samples),!D&&this.config.forceKeyFrameOnDiscontinuity))if(f=!0,I>0){w.warn("[mp4-remuxer]: Dropped "+I+" out of "+y+" video samples due to a missing keyframe");var C=this.getVideoStartPts(e.samples);e.samples=e.samples.slice(I),e.dropped+=I,b=v+=(e.samples[0].pts-C)/e.inputTimeScale}else-1===I&&(w.warn("[mp4-remuxer]: No keyframe found out of "+y+" video samples"),f=!1);if(this.ISGenerated){if(E&&T){var _=this.getVideoStartPts(e.samples),x=(Sn(t.samples[0].pts,_)-_)/e.inputTimeScale;g+=Math.max(0,x),v+=Math.max(0,-x)}if(E){if(t.samplerate||(w.warn("[mp4-remuxer]: regenerate InitSegment as audio detected"),h=this.generateIS(t,e,n,a)),u=this.remuxAudio(t,g,this.isAudioContiguous,a,p||T||o===we?v:void 0),T){var P=u?u.endPTS-u.startPTS:0;e.inputTimeScale||(w.warn("[mp4-remuxer]: regenerate InitSegment as video detected"),h=this.generateIS(t,e,n,a)),l=this.remuxVideo(e,v,D,P)}}else T&&(l=this.remuxVideo(e,v,D,0));l&&(l.firstKeyFrame=I,l.independent=-1!==I,l.firstKeyFramePTS=b)}}return this.ISGenerated&&this._initPTS&&this._initDTS&&(r.samples.length&&(c=Ln(r,n,this._initPTS,this._initDTS)),i.samples.length&&(d=An(i,n,this._initPTS))),{audio:u,video:l,initSegment:h,independent:f,text:d,id3:c}},e.generateIS=function(t,e,r,i){var n,a,s,o=t.samples,l=e.samples,u=this.typeSupported,h={},d=this._initPTS,c=!d||i,f="audio/mp4";if(c&&(n=a=1/0),t.config&&o.length){switch(t.timescale=t.samplerate,t.segmentCodec){case"mp3":u.mpeg?(f="audio/mpeg",t.codec=""):u.mp3&&(t.codec="mp3");break;case"ac3":t.codec="ac-3"}h.audio={id:"audio",container:f,codec:t.codec,initSegment:"mp3"===t.segmentCodec&&u.mpeg?new Uint8Array(0):gn.initSegment([t]),metadata:{channelCount:t.channelCount}},c&&(s=t.inputTimeScale,d&&s===d.timescale?c=!1:n=a=o[0].pts-Math.round(s*r))}if(e.sps&&e.pps&&l.length){if(e.timescale=e.inputTimeScale,h.video={id:"main",container:"video/mp4",codec:e.codec,initSegment:gn.initSegment([e]),metadata:{width:e.width,height:e.height}},c)if(s=e.inputTimeScale,d&&s===d.timescale)c=!1;else{var g=this.getVideoStartPts(l),v=Math.round(s*r);a=Math.min(a,Sn(l[0].dts,g)-v),n=Math.min(n,g-v)}this.videoTrackConfig={width:e.width,height:e.height,pixelRatio:e.pixelRatio}}if(Object.keys(h).length)return this.ISGenerated=!0,c?(this._initPTS={baseTime:n,timescale:s},this._initDTS={baseTime:a,timescale:s}):n=s=void 0,{tracks:h,initPTS:n,timescale:s}},e.remuxVideo=function(t,e,r,i){var n,a,s=t.inputTimeScale,l=t.samples,u=[],h=l.length,d=this._initPTS,c=this.nextAvcDts,f=8,g=this.videoSampleDuration,v=Number.POSITIVE_INFINITY,m=Number.NEGATIVE_INFINITY,p=!1;if(!r||null===c){var y=e*s,E=l[0].pts-Sn(l[0].dts,l[0].pts);yn&&null!==c&&Math.abs(y-E-c)<15e3?r=!0:c=y-E}for(var T=d.baseTime*s/d.timescale,R=0;R<h;R++){var k=l[R];k.pts=Sn(k.pts-T,c),k.dts=Sn(k.dts-T,c),k.dts<l[R>0?R-1:R].dts&&(p=!0)}p&&l.sort((function(t,e){var r=t.dts-e.dts,i=t.pts-e.pts;return r||i})),n=l[0].dts;var b=(a=l[l.length-1].dts)-n,D=b?Math.round(b/(h-1)):g||t.inputTimeScale/30;if(r){var I=n-c,C=I>D,_=I<-1;if((C||_)&&(C?w.warn("AVC: "+pn(I,!0)+" ms ("+I+"dts) hole between fragments detected at "+e.toFixed(3)):w.warn("AVC: "+pn(-I,!0)+" ms ("+I+"dts) overlapping between fragments detected at "+e.toFixed(3)),!_||c>=l[0].pts||yn)){n=c;var x=l[0].pts-I;if(C)l[0].dts=n,l[0].pts=x;else for(var P=0;P<l.length&&!(l[P].dts>x);P++)l[P].dts-=I,l[P].pts-=I;w.log("Video: Initial PTS/DTS adjusted: "+pn(x,!0)+"/"+pn(n,!0)+", delta: "+pn(I,!0)+" ms")}}for(var F=0,M=0,O=n=Math.max(0,n),N=0;N<h;N++){for(var U=l[N],B=U.units,G=B.length,K=0,H=0;H<G;H++)K+=B[H].data.length;M+=K,F+=G,U.length=K,U.dts<O?(U.dts=O,O+=D/4|0||1):O=U.dts,v=Math.min(U.pts,v),m=Math.max(U.pts,m)}a=l[h-1].dts;var V,Y=M+4*F+8;try{V=new Uint8Array(Y)}catch(t){return void this.observer.emit(S.ERROR,S.ERROR,{type:L.MUX_ERROR,details:A.REMUX_ALLOC_ERROR,fatal:!1,error:t,bytes:Y,reason:"fail allocating video mdat "+Y})}var W=new DataView(V.buffer);W.setUint32(0,Y),V.set(gn.types.mdat,4);for(var j=!1,q=Number.POSITIVE_INFINITY,X=Number.POSITIVE_INFINITY,z=Number.NEGATIVE_INFINITY,Q=Number.NEGATIVE_INFINITY,J=0;J<h;J++){for(var $=l[J],Z=$.units,tt=0,et=0,rt=Z.length;et<rt;et++){var it=Z[et],nt=it.data,at=it.data.byteLength;W.setUint32(f,at),f+=4,V.set(nt,f),f+=at,tt+=4+at}var st=void 0;if(J<h-1)g=l[J+1].dts-$.dts,st=l[J+1].pts-$.pts;else{var ot=this.config,lt=J>0?$.dts-l[J-1].dts:D;if(st=J>0?$.pts-l[J-1].pts:D,ot.stretchShortVideoTrack&&null!==this.nextAudioPts){var ut=Math.floor(ot.maxBufferHole*s),ht=(i?v+i*s:this.nextAudioPts)-$.pts;ht>ut?((g=ht-lt)<0?g=lt:j=!0,w.log("[mp4-remuxer]: It is approximately "+ht/90+" ms to the next segment; using duration "+g/90+" ms for the last video frame.")):g=lt}else g=lt}var dt=Math.round($.pts-$.dts);q=Math.min(q,g),z=Math.max(z,g),X=Math.min(X,st),Q=Math.max(Q,st),u.push(new kn($.key,g,tt,dt))}if(u.length)if(yn){if(yn<70){var ct=u[0].flags;ct.dependsOn=2,ct.isNonSync=0}}else if(En&&Q-X<z-q&&D/z<.025&&0===u[0].cts){w.warn("Found irregular gaps in sample duration. Using PTS instead of DTS to determine MP4 sample duration.");for(var ft=n,gt=0,vt=u.length;gt<vt;gt++){var mt=ft+u[gt].duration,pt=ft+u[gt].cts;if(gt<vt-1){var yt=mt+u[gt+1].cts;u[gt].duration=yt-pt}else u[gt].duration=gt?u[gt-1].duration:D;u[gt].cts=0,ft=mt}}g=j||!g?D:g,this.nextAvcDts=c=a+g,this.videoSampleDuration=g,this.isVideoContiguous=!0;var Et={data1:gn.moof(t.sequenceNumber++,n,o({},t,{samples:u})),data2:V,startPTS:v/s,endPTS:(m+g)/s,startDTS:n/s,endDTS:c/s,type:"video",hasAudio:!1,hasVideo:!0,nb:u.length,dropped:t.dropped};return t.samples=[],t.dropped=0,Et},e.getSamplesPerFrame=function(t){switch(t.segmentCodec){case"mp3":return 1152;case"ac3":return 1536;default:return 1024}},e.remuxAudio=function(t,e,r,i,n){var a=t.inputTimeScale,s=a/(t.samplerate?t.samplerate:a),l=this.getSamplesPerFrame(t),u=l*s,h=this._initPTS,d="mp3"===t.segmentCodec&&this.typeSupported.mpeg,c=[],f=void 0!==n,g=t.samples,v=d?0:8,m=this.nextAudioPts||-1,p=e*a,y=h.baseTime*a/h.timescale;if(this.isAudioContiguous=r=r||g.length&&m>0&&(i&&Math.abs(p-m)<9e3||Math.abs(Sn(g[0].pts-y,p)-m)<20*u),g.forEach((function(t){t.pts=Sn(t.pts-y,p)})),!r||m<0){if(g=g.filter((function(t){return t.pts>=0})),!g.length)return;m=0===n?0:i&&!f?Math.max(0,p):g[0].pts}if("aac"===t.segmentCodec)for(var E=this.config.maxAudioFramesDrift,T=0,R=m;T<g.length;T++){var k=g[T],b=k.pts,D=b-R,I=Math.abs(1e3*D/a);if(D<=-E*u&&f)0===T&&(w.warn("Audio frame @ "+(b/a).toFixed(3)+"s overlaps nextAudioPts by "+Math.round(1e3*D/a)+" ms."),this.nextAudioPts=m=R=b);else if(D>=E*u&&I<1e4&&f){var C=Math.round(D/u);(R=b-C*u)<0&&(C--,R+=u),0===T&&(this.nextAudioPts=m=R),w.warn("[mp4-remuxer]: Injecting "+C+" audio frame @ "+(R/a).toFixed(3)+"s due to "+Math.round(1e3*D/a)+" ms gap.");for(var _=0;_<C;_++){var x=Math.max(R,0),P=cn.getSilentFrame(t.manifestCodec||t.codec,t.channelCount);P||(w.log("[mp4-remuxer]: Unable to get silent frame for given audio codec; duplicating last frame instead."),P=k.unit.subarray()),g.splice(T,0,{unit:P,pts:x}),R+=u,T++}}k.pts=R,R+=u}for(var F,M=null,O=null,N=0,U=g.length;U--;)N+=g[U].unit.byteLength;for(var B=0,G=g.length;B<G;B++){var K=g[B],H=K.unit,V=K.pts;if(null!==O)c[B-1].duration=Math.round((V-O)/s);else{if(r&&"aac"===t.segmentCodec&&(V=m),M=V,!(N>0))return;N+=v;try{F=new Uint8Array(N)}catch(t){return void this.observer.emit(S.ERROR,S.ERROR,{type:L.MUX_ERROR,details:A.REMUX_ALLOC_ERROR,fatal:!1,error:t,bytes:N,reason:"fail allocating audio mdat "+N})}d||(new DataView(F.buffer).setUint32(0,N),F.set(gn.types.mdat,4))}F.set(H,v);var Y=H.byteLength;v+=Y,c.push(new kn(!0,l,Y,0)),O=V}var W=c.length;if(W){var j=c[c.length-1];this.nextAudioPts=m=O+s*j.duration;var q=d?new Uint8Array(0):gn.moof(t.sequenceNumber++,M/s,o({},t,{samples:c}));t.samples=[];var X=M/a,z=m/a,Q={data1:q,data2:F,startPTS:X,endPTS:z,startDTS:X,endDTS:z,type:"audio",hasAudio:!0,hasVideo:!1,nb:W};return this.isAudioContiguous=!0,Q}},e.remuxEmptyAudio=function(t,e,r,i){var n=t.inputTimeScale,a=n/(t.samplerate?t.samplerate:n),s=this.nextAudioPts,o=this._initDTS,l=9e4*o.baseTime/o.timescale,u=(null!==s?s:i.startDTS*n)+l,h=i.endDTS*n+l,d=1024*a,c=Math.ceil((h-u)/d),f=cn.getSilentFrame(t.manifestCodec||t.codec,t.channelCount);if(w.warn("[mp4-remuxer]: remux empty Audio"),f){for(var g=[],v=0;v<c;v++){var m=u+v*d;g.push({unit:f,pts:m,dts:m})}return t.samples=g,this.remuxAudio(t,e,r,!1)}w.trace("[mp4-remuxer]: Unable to remuxEmptyAudio since we were unable to get a silent frame for given audio codec")},t}();function Sn(t,e){var r;if(null===e)return t;for(r=e<t?-8589934592:8589934592;Math.abs(t-e)>4294967296;)t+=r;return t}function Ln(t,e,r,i){var n=t.samples.length;if(n){for(var a=t.inputTimeScale,s=0;s<n;s++){var o=t.samples[s];o.pts=Sn(o.pts-r.baseTime*a/r.timescale,e*a)/a,o.dts=Sn(o.dts-i.baseTime*a/i.timescale,e*a)/a}var l=t.samples;return t.samples=[],{samples:l}}}function An(t,e,r){var i=t.samples.length;if(i){for(var n=t.inputTimeScale,a=0;a<i;a++){var s=t.samples[a];s.pts=Sn(s.pts-r.baseTime*n/r.timescale,e*n)/n}t.samples.sort((function(t,e){return t.pts-e.pts}));var o=t.samples;return t.samples=[],{samples:o}}}var Rn,kn=function(t,e,r,i){this.size=void 0,this.duration=void 0,this.cts=void 0,this.flags=void 0,this.duration=e,this.size=r,this.cts=i,this.flags={isLeading:0,isDependedOn:0,hasRedundancy:0,degradPrio:0,dependsOn:t?2:1,isNonSync:t?0:1}},bn=function(){function t(){this.emitInitSegment=!1,this.audioCodec=void 0,this.videoCodec=void 0,this.initData=void 0,this.initPTS=null,this.initTracks=void 0,this.lastEndTime=null}var e=t.prototype;return e.destroy=function(){},e.resetTimeStamp=function(t){this.initPTS=t,this.lastEndTime=null},e.resetNextTimestamp=function(){this.lastEndTime=null},e.resetInitSegment=function(t,e,r,i){this.audioCodec=e,this.videoCodec=r,this.generateInitSegment(function(t,e){if(!t||!e)return t;var r=e.keyId;return r&&e.isCommonEncryption&&_t(t,["moov","trak"]).forEach((function(t){var e=_t(t,["mdia","minf","stbl","stsd"])[0].subarray(8),i=_t(e,["enca"]),n=i.length>0;n||(i=_t(e,["encv"])),i.forEach((function(t){_t(n?t.subarray(28):t.subarray(78),["sinf"]).forEach((function(t){var e=Ut(t);if(e){var i=e.subarray(8,24);i.some((function(t){return 0!==t}))||(w.log("[eme] Patching keyId in 'enc"+(n?"a":"v")+">sinf>>tenc' box: "+Lt(i)+" -> "+Lt(r)),e.set(r,8))}}))}))})),t}(t,i)),this.emitInitSegment=!0},e.generateInitSegment=function(t){var e=this.audioCodec,r=this.videoCodec;if(null==t||!t.byteLength)return this.initTracks=void 0,void(this.initData=void 0);var i=this.initData=Pt(t);i.audio&&(e=Dn(i.audio,O)),i.video&&(r=Dn(i.video,N));var n={};i.audio&&i.video?n.audiovideo={container:"video/mp4",codec:e+","+r,initSegment:t,id:"main"}:i.audio?n.audio={container:"audio/mp4",codec:e,initSegment:t,id:"audio"}:i.video?n.video={container:"video/mp4",codec:r,initSegment:t,id:"main"}:w.warn("[passthrough-remuxer.ts]: initSegment does not contain moov or trak boxes."),this.initTracks=n},e.remux=function(t,e,r,i,n,a){var s,o,l=this.initPTS,u=this.lastEndTime,h={audio:void 0,video:void 0,text:i,id3:r,initSegment:void 0};y(u)||(u=this.lastEndTime=n||0);var d=e.samples;if(null==d||!d.length)return h;var c={initPTS:void 0,timescale:1},f=this.initData;if(null!=(s=f)&&s.length||(this.generateInitSegment(d),f=this.initData),null==(o=f)||!o.length)return w.warn("[passthrough-remuxer.ts]: Failed to generate initSegment."),h;this.emitInitSegment&&(c.tracks=this.initTracks,this.emitInitSegment=!1);var g=function(t,e){for(var r=0,i=0,n=0,a=_t(t,["moof","traf"]),s=0;s<a.length;s++){var o=a[s],l=_t(o,["tfhd"])[0],u=e[It(l,4)];if(u){var h=u.default,d=It(l,0)|(null==h?void 0:h.flags),c=null==h?void 0:h.duration;8&d&&(c=It(l,2&d?12:8));for(var f=u.timescale||9e4,g=_t(o,["trun"]),v=0;v<g.length;v++)!(r=Bt(g[v]))&&c&&(r=c*It(g[v],4)),u.type===N?i+=r/f:u.type===O&&(n+=r/f)}}if(0===i&&0===n){for(var m=0,p=_t(t,["sidx"]),y=0;y<p.length;y++){var E=xt(p[y]);null!=E&&E.references&&(m+=E.references.reduce((function(t,e){return t+e.info.duration||0}),0))}return m}return i||n}(d,f),v=function(t,e){return _t(e,["moof","traf"]).reduce((function(e,r){var i=_t(r,["tfdt"])[0],n=i[0],a=_t(r,["tfhd"]).reduce((function(e,r){var a=It(r,4),s=t[a];if(s){var o=It(i,4);if(1===n){if(o===At)return w.warn("[mp4-demuxer]: Ignoring assumed invalid signed 64-bit track fragment decode time"),e;o*=At+1,o+=It(i,8)}var l=o/(s.timescale||9e4);if(y(l)&&(null===e||l<e))return l}return e}),null);return null!==a&&y(a)&&(null===e||a<e)?a:e}),null)}(f,d),m=null===v?n:v;(function(t,e,r,i){if(null===t)return!0;var n=Math.max(i,1),a=e-t.baseTime/t.timescale;return Math.abs(a-r)>n}(l,m,n,g)||c.timescale!==l.timescale&&a)&&(c.initPTS=m-n,l&&1===l.timescale&&w.warn("Adjusting initPTS by "+(c.initPTS-l.baseTime)),this.initPTS=l={baseTime:c.initPTS,timescale:1});var p=t?m-l.baseTime/l.timescale:u,E=p+g;!function(t,e,r){_t(e,["moof","traf"]).forEach((function(e){_t(e,["tfhd"]).forEach((function(i){var n=It(i,4),a=t[n];if(a){var s=a.timescale||9e4;_t(e,["tfdt"]).forEach((function(t){var e=t[0],i=r*s;if(i){var n=It(t,4);if(0===e)n-=i,Ct(t,4,n=Math.max(n,0));else{n*=Math.pow(2,32),n+=It(t,8),n-=i,n=Math.max(n,0);var a=Math.floor(n/(At+1)),o=Math.floor(n%(At+1));Ct(t,4,a),Ct(t,8,o)}}}))}}))}))}(f,d,l.baseTime/l.timescale),g>0?this.lastEndTime=E:(w.warn("Duration parsed from mp4 should be greater than zero"),this.resetNextTimestamp());var T=!!f.audio,S=!!f.video,L="";T&&(L+="audio"),S&&(L+="video");var A={data1:d,startPTS:p,startDTS:p,endPTS:E,endDTS:E,type:L,hasAudio:T,hasVideo:S,nb:1,dropped:0};return h.audio="audio"===A.type?A:void 0,h.video="audio"!==A.type?A:void 0,h.initSegment=c,h.id3=Ln(r,n,l,l),i.samples.length&&(h.text=An(i,n,l)),h},t}();function Dn(t,e){var r=null==t?void 0:t.codec;if(r&&r.length>4)return r;if(e===O){if("ec-3"===r||"ac-3"===r||"alac"===r)return r;if("fLaC"===r||"Opus"===r)return ue(r,!1);var i="mp4a.40.5";return w.info('Parsed audio codec "'+r+'" or audio object type not handled. Using "'+i+'"'),i}return w.warn('Unhandled video codec "'+r+'"'),"hvc1"===r||"hev1"===r?"hvc1.1.6.L120.90":"av01"===r?"av01.0.04M.08":"avc1.42e01e"}try{Rn=self.performance.now.bind(self.performance)}catch(t){w.debug("Unable to use Performance API on this environment"),Rn=null==j?void 0:j.Date.now}var In=[{demux:zi,remux:bn},{demux:an,remux:Tn},{demux:qi,remux:Tn},{demux:dn,remux:Tn}];In.splice(2,0,{demux:Ji,remux:Tn});var wn=function(){function t(t,e,r,i,n){this.async=!1,this.observer=void 0,this.typeSupported=void 0,this.config=void 0,this.vendor=void 0,this.id=void 0,this.demuxer=void 0,this.remuxer=void 0,this.decrypter=void 0,this.probe=void 0,this.decryptionPromise=null,this.transmuxConfig=void 0,this.currentTransmuxState=void 0,this.observer=t,this.typeSupported=e,this.config=r,this.vendor=i,this.id=n}var e=t.prototype;return e.configure=function(t){this.transmuxConfig=t,this.decrypter&&this.decrypter.reset()},e.push=function(t,e,r,i){var n=this,a=r.transmuxing;a.executeStart=Rn();var s=new Uint8Array(t),o=this.currentTransmuxState,l=this.transmuxConfig;i&&(this.currentTransmuxState=i);var u=i||o,h=u.contiguous,d=u.discontinuity,c=u.trackSwitch,f=u.accurateTimeOffset,g=u.timeOffset,v=u.initSegmentChange,m=l.audioCodec,p=l.videoCodec,y=l.defaultInitPts,E=l.duration,T=l.initSegmentData,R=function(t,e){var r=null;return t.byteLength>0&&null!=(null==e?void 0:e.key)&&null!==e.iv&&null!=e.method&&(r=e),r}(s,e);if(R&&"AES-128"===R.method){var k=this.getDecrypter();if(!k.isSync())return this.decryptionPromise=k.webCryptoDecrypt(s,R.key.buffer,R.iv.buffer).then((function(t){var e=n.push(t,null,r);return n.decryptionPromise=null,e})),this.decryptionPromise;var b=k.softwareDecrypt(s,R.key.buffer,R.iv.buffer);if(r.part>-1&&(b=k.flush()),!b)return a.executeEnd=Rn(),Cn(r);s=new Uint8Array(b)}var D=this.needsProbing(d,c);if(D){var I=this.configureTransmuxer(s);if(I)return w.warn("[transmuxer] "+I.message),this.observer.emit(S.ERROR,S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_PARSING_ERROR,fatal:!1,error:I,reason:I.message}),a.executeEnd=Rn(),Cn(r)}(d||c||v||D)&&this.resetInitSegment(T,m,p,E,e),(d||v||D)&&this.resetInitialTimestamp(y),h||this.resetContiguity();var C=this.transmux(s,R,g,f,r),_=this.currentTransmuxState;return _.contiguous=!0,_.discontinuity=!1,_.trackSwitch=!1,a.executeEnd=Rn(),C},e.flush=function(t){var e=this,r=t.transmuxing;r.executeStart=Rn();var i=this.decrypter,n=this.currentTransmuxState,a=this.decryptionPromise;if(a)return a.then((function(){return e.flush(t)}));var s=[],o=n.timeOffset;if(i){var l=i.flush();l&&s.push(this.push(l,null,t))}var u=this.demuxer,h=this.remuxer;if(!u||!h)return r.executeEnd=Rn(),[Cn(t)];var d=u.flush(o);return _n(d)?d.then((function(r){return e.flushRemux(s,r,t),s})):(this.flushRemux(s,d,t),s)},e.flushRemux=function(t,e,r){var i=e.audioTrack,n=e.videoTrack,a=e.id3Track,s=e.textTrack,o=this.currentTransmuxState,l=o.accurateTimeOffset,u=o.timeOffset;w.log("[transmuxer.ts]: Flushed fragment "+r.sn+(r.part>-1?" p: "+r.part:"")+" of level "+r.level);var h=this.remuxer.remux(i,n,a,s,u,l,!0,this.id);t.push({remuxResult:h,chunkMeta:r}),r.transmuxing.executeEnd=Rn()},e.resetInitialTimestamp=function(t){var e=this.demuxer,r=this.remuxer;e&&r&&(e.resetTimeStamp(t),r.resetTimeStamp(t))},e.resetContiguity=function(){var t=this.demuxer,e=this.remuxer;t&&e&&(t.resetContiguity(),e.resetNextTimestamp())},e.resetInitSegment=function(t,e,r,i,n){var a=this.demuxer,s=this.remuxer;a&&s&&(a.resetInitSegment(t,e,r,i),s.resetInitSegment(t,e,r,n))},e.destroy=function(){this.demuxer&&(this.demuxer.destroy(),this.demuxer=void 0),this.remuxer&&(this.remuxer.destroy(),this.remuxer=void 0)},e.transmux=function(t,e,r,i,n){return e&&"SAMPLE-AES"===e.method?this.transmuxSampleAes(t,e,r,i,n):this.transmuxUnencrypted(t,r,i,n)},e.transmuxUnencrypted=function(t,e,r,i){var n=this.demuxer.demux(t,e,!1,!this.config.progressive),a=n.audioTrack,s=n.videoTrack,o=n.id3Track,l=n.textTrack;return{remuxResult:this.remuxer.remux(a,s,o,l,e,r,!1,this.id),chunkMeta:i}},e.transmuxSampleAes=function(t,e,r,i,n){var a=this;return this.demuxer.demuxSampleAes(t,e,r).then((function(t){return{remuxResult:a.remuxer.remux(t.audioTrack,t.videoTrack,t.id3Track,t.textTrack,r,i,!1,a.id),chunkMeta:n}}))},e.configureTransmuxer=function(t){for(var e,r=this.config,i=this.observer,n=this.typeSupported,a=this.vendor,s=0,o=In.length;s<o;s++){var l;if(null!=(l=In[s].demux)&&l.probe(t)){e=In[s];break}}if(!e)return new Error("Failed to find demuxer by probing fragment data");var u=this.demuxer,h=this.remuxer,d=e.remux,c=e.demux;h&&h instanceof d||(this.remuxer=new d(i,r,n,a)),u&&u instanceof c||(this.demuxer=new c(i,r,n),this.probe=c.probe)},e.needsProbing=function(t,e){return!this.demuxer||!this.remuxer||t||e},e.getDecrypter=function(){var t=this.decrypter;return t||(t=this.decrypter=new hi(this.config)),t},t}(),Cn=function(t){return{remuxResult:{},chunkMeta:t}};function _n(t){return"then"in t&&t.then instanceof Function}var xn=function(t,e,r,i,n){this.audioCodec=void 0,this.videoCodec=void 0,this.initSegmentData=void 0,this.duration=void 0,this.defaultInitPts=void 0,this.audioCodec=t,this.videoCodec=e,this.initSegmentData=r,this.duration=i,this.defaultInitPts=n||null},Pn=function(t,e,r,i,n,a){this.discontinuity=void 0,this.contiguous=void 0,this.accurateTimeOffset=void 0,this.trackSwitch=void 0,this.timeOffset=void 0,this.initSegmentChange=void 0,this.discontinuity=t,this.contiguous=e,this.accurateTimeOffset=r,this.trackSwitch=i,this.timeOffset=n,this.initSegmentChange=a},Fn={exports:{}};!function(t){var e=Object.prototype.hasOwnProperty,r="~";function i(){}function n(t,e,r){this.fn=t,this.context=e,this.once=r||!1}function a(t,e,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||t,s),l=r?r+e:e;return t._events[l]?t._events[l].fn?t._events[l]=[t._events[l],o]:t._events[l].push(o):(t._events[l]=o,t._eventsCount++),t}function s(t,e){0==--t._eventsCount?t._events=new i:delete t._events[e]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var t,i,n=[];if(0===this._eventsCount)return n;for(i in t=this._events)e.call(t,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(t)):n},o.prototype.listeners=function(t){var e=r?r+t:t,i=this._events[e];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;return s},o.prototype.listenerCount=function(t){var e=r?r+t:t,i=this._events[e];return i?i.fn?1:i.length:0},o.prototype.emit=function(t,e,i,n,a,s){var o=r?r+t:t;if(!this._events[o])return!1;var l,u,h=this._events[o],d=arguments.length;if(h.fn){switch(h.once&&this.removeListener(t,h.fn,void 0,!0),d){case 1:return h.fn.call(h.context),!0;case 2:return h.fn.call(h.context,e),!0;case 3:return h.fn.call(h.context,e,i),!0;case 4:return h.fn.call(h.context,e,i,n),!0;case 5:return h.fn.call(h.context,e,i,n,a),!0;case 6:return h.fn.call(h.context,e,i,n,a,s),!0}for(u=1,l=new Array(d-1);u<d;u++)l[u-1]=arguments[u];h.fn.apply(h.context,l)}else{var c,f=h.length;for(u=0;u<f;u++)switch(h[u].once&&this.removeListener(t,h[u].fn,void 0,!0),d){case 1:h[u].fn.call(h[u].context);break;case 2:h[u].fn.call(h[u].context,e);break;case 3:h[u].fn.call(h[u].context,e,i);break;case 4:h[u].fn.call(h[u].context,e,i,n);break;default:if(!l)for(c=1,l=new Array(d-1);c<d;c++)l[c-1]=arguments[c];h[u].fn.apply(h[u].context,l)}}return!0},o.prototype.on=function(t,e,r){return a(this,t,e,r,!1)},o.prototype.once=function(t,e,r){return a(this,t,e,r,!0)},o.prototype.removeListener=function(t,e,i,n){var a=r?r+t:t;if(!this._events[a])return this;if(!e)return s(this,a),this;var o=this._events[a];if(o.fn)o.fn!==e||n&&!o.once||i&&o.context!==i||s(this,a);else{for(var l=0,u=[],h=o.length;l<h;l++)(o[l].fn!==e||n&&!o[l].once||i&&o[l].context!==i)&&u.push(o[l]);u.length?this._events[a]=1===u.length?u[0]:u:s(this,a)}return this},o.prototype.removeAllListeners=function(t){var e;return t?(e=r?r+t:t,this._events[e]&&s(this,e)):(this._events=new i,this._eventsCount=0),this},o.prototype.off=o.prototype.removeListener,o.prototype.addListener=o.prototype.on,o.prefixed=r,o.EventEmitter=o,t.exports=o}(Fn);var Mn=v(Fn.exports);function On(t,e){if(!((r=e.remuxResult).audio||r.video||r.text||r.id3||r.initSegment))return!1;var r,i=[],n=e.remuxResult,a=n.audio,s=n.video;return a&&Nn(i,a),s&&Nn(i,s),t.postMessage({event:"transmuxComplete",data:e},i),!0}function Nn(t,e){e.data1&&t.push(e.data1.buffer),e.data2&&t.push(e.data2.buffer)}function Un(t,e,r){e.reduce((function(e,r){return On(t,r)||e}),!1)||t.postMessage({event:"transmuxComplete",data:e[0]}),t.postMessage({event:"flush",data:r})}void 0!==e&&e&&function(t){var e=new Mn,r=function(e,r){t.postMessage({event:e,data:r})};e.on(S.FRAG_DECRYPTED,r),e.on(S.ERROR,r);var i=function(){var t=function(t){var e=function(e){r("workerLog",{logType:t,message:e})};w[t]=e};for(var e in w)t(e)};t.addEventListener("message",(function(n){var a=n.data;switch(a.cmd){case"init":var s=JSON.parse(a.config);t.transmuxer=new wn(e,a.typeSupported,s,a.vendor,a.id),I(s.debug,a.id),i(),r("init",null);break;case"configure":t.transmuxer.configure(a.config);break;case"demux":var o=t.transmuxer.push(a.data,a.decryptdata,a.chunkMeta,a.state);_n(o)?(t.transmuxer.async=!0,o.then((function(e){On(t,e)})).catch((function(t){r(S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_PARSING_ERROR,chunkMeta:a.chunkMeta,fatal:!1,error:t,err:t,reason:"transmuxer-worker push error"})}))):(t.transmuxer.async=!1,On(t,o));break;case"flush":var l=a.chunkMeta,u=t.transmuxer.flush(l);_n(u)||t.transmuxer.async?(_n(u)||(u=Promise.resolve(u)),u.then((function(e){Un(t,e,l)})).catch((function(t){r(S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_PARSING_ERROR,chunkMeta:a.chunkMeta,fatal:!1,error:t,err:t,reason:"transmuxer-worker flush error"})}))):Un(t,u,l)}}))}(self);var Bn=function(){function e(e,r,i,n){var a=this;this.error=null,this.hls=void 0,this.id=void 0,this.observer=void 0,this.frag=null,this.part=null,this.useWorker=void 0,this.workerContext=null,this.onwmsg=void 0,this.transmuxer=null,this.onTransmuxComplete=void 0,this.onFlush=void 0;var s=e.config;this.hls=e,this.id=r,this.useWorker=!!s.enableWorker,this.onTransmuxComplete=i,this.onFlush=n;var o=function(t,e){(e=e||{}).frag=a.frag,e.id=a.id,t===S.ERROR&&(a.error=e.error),a.hls.trigger(t,e)};this.observer=new Mn,this.observer.on(S.FRAG_DECRYPTED,o),this.observer.on(S.ERROR,o);var l,u,h,d,c=te(s.preferManagedMediaSource)||{isTypeSupported:function(){return!1}},f={mpeg:c.isTypeSupported("audio/mpeg"),mp3:c.isTypeSupported('audio/mp4; codecs="mp3"'),ac3:c.isTypeSupported('audio/mp4; codecs="ac-3"')},g=navigator.vendor;if(!this.useWorker||"undefined"==typeof Worker||(s.workerPath,0))this.transmuxer=new wn(this.observer,f,s,g,r);else try{s.workerPath?(w.log("loading Web Worker "+s.workerPath+' for "'+r+'"'),this.workerContext=(h=s.workerPath,d=new self.URL(h,self.location.href).href,{worker:new self.Worker(d),scriptURL:d})):(w.log('injecting Web Worker for "'+r+'"'),this.workerContext=(l=new self.Blob(["var exports={};var module={exports:exports};function define(f){f()};define.amd=true;("+t.toString()+")(true);"],{type:"text/javascript"}),u=self.URL.createObjectURL(l),{worker:new self.Worker(u),objectURL:u})),this.onwmsg=function(t){return a.onWorkerMessage(t)};var v=this.workerContext.worker;v.addEventListener("message",this.onwmsg),v.onerror=function(t){var e=new Error(t.message+" ("+t.filename+":"+t.lineno+")");s.enableWorker=!1,w.warn('Error in "'+r+'" Web Worker, fallback to inline'),a.hls.trigger(S.ERROR,{type:L.OTHER_ERROR,details:A.INTERNAL_EXCEPTION,fatal:!1,event:"demuxerWorker",error:e})},v.postMessage({cmd:"init",typeSupported:f,vendor:g,id:r,config:JSON.stringify(s)})}catch(t){w.warn('Error setting up "'+r+'" Web Worker, fallback to inline',t),this.resetWorker(),this.error=null,this.transmuxer=new wn(this.observer,f,s,g,r)}}var r=e.prototype;return r.resetWorker=function(){if(this.workerContext){var t=this.workerContext,e=t.worker,r=t.objectURL;r&&self.URL.revokeObjectURL(r),e.removeEventListener("message",this.onwmsg),e.onerror=null,e.terminate(),this.workerContext=null}},r.destroy=function(){if(this.workerContext)this.resetWorker(),this.onwmsg=void 0;else{var t=this.transmuxer;t&&(t.destroy(),this.transmuxer=null)}var e=this.observer;e&&e.removeAllListeners(),this.frag=null,this.observer=null,this.hls=null},r.push=function(t,e,r,i,n,a,s,o,l,u){var h,d,c=this;l.transmuxing.start=self.performance.now();var f=this.transmuxer,g=a?a.start:n.start,v=n.decryptdata,m=this.frag,p=!(m&&n.cc===m.cc),y=!(m&&l.level===m.level),E=m?l.sn-m.sn:-1,T=this.part?l.part-this.part.index:-1,S=0===E&&l.id>1&&l.id===(null==m?void 0:m.stats.chunkCount),L=!y&&(1===E||0===E&&(1===T||S&&T<=0)),A=self.performance.now();(y||E||0===n.stats.parsing.start)&&(n.stats.parsing.start=A),!a||!T&&L||(a.stats.parsing.start=A);var R=!(m&&(null==(h=n.initSegment)?void 0:h.url)===(null==(d=m.initSegment)?void 0:d.url)),k=new Pn(p,L,o,y,g,R);if(!L||p||R){w.log("[transmuxer-interface, "+n.type+"]: Starting new transmux session for sn: "+l.sn+" p: "+l.part+" level: "+l.level+" id: "+l.id+"\n discontinuity: "+p+"\n trackSwitch: "+y+"\n contiguous: "+L+"\n accurateTimeOffset: "+o+"\n timeOffset: "+g+"\n initSegmentChange: "+R);var b=new xn(r,i,e,s,u);this.configureTransmuxer(b)}if(this.frag=n,this.part=a,this.workerContext)this.workerContext.worker.postMessage({cmd:"demux",data:t,decryptdata:v,chunkMeta:l,state:k},t instanceof ArrayBuffer?[t]:[]);else if(f){var D=f.push(t,v,l,k);_n(D)?(f.async=!0,D.then((function(t){c.handleTransmuxComplete(t)})).catch((function(t){c.transmuxerError(t,l,"transmuxer-interface push error")}))):(f.async=!1,this.handleTransmuxComplete(D))}},r.flush=function(t){var e=this;t.transmuxing.start=self.performance.now();var r=this.transmuxer;if(this.workerContext)this.workerContext.worker.postMessage({cmd:"flush",chunkMeta:t});else if(r){var i=r.flush(t);_n(i)||r.async?(_n(i)||(i=Promise.resolve(i)),i.then((function(r){e.handleFlushResult(r,t)})).catch((function(r){e.transmuxerError(r,t,"transmuxer-interface flush error")}))):this.handleFlushResult(i,t)}},r.transmuxerError=function(t,e,r){this.hls&&(this.error=t,this.hls.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_PARSING_ERROR,chunkMeta:e,fatal:!1,error:t,err:t,reason:r}))},r.handleFlushResult=function(t,e){var r=this;t.forEach((function(t){r.handleTransmuxComplete(t)})),this.onFlush(e)},r.onWorkerMessage=function(t){var e=t.data,r=this.hls;switch(e.event){case"init":var i,n=null==(i=this.workerContext)?void 0:i.objectURL;n&&self.URL.revokeObjectURL(n);break;case"transmuxComplete":this.handleTransmuxComplete(e.data);break;case"flush":this.onFlush(e.data);break;case"workerLog":w[e.data.logType]&&w[e.data.logType](e.data.message);break;default:e.data=e.data||{},e.data.frag=this.frag,e.data.id=this.id,r.trigger(e.event,e.data)}},r.configureTransmuxer=function(t){var e=this.transmuxer;this.workerContext?this.workerContext.worker.postMessage({cmd:"configure",config:t}):e&&e.configure(t)},r.handleTransmuxComplete=function(t){t.chunkMeta.transmuxing.end=self.performance.now(),this.onTransmuxComplete(t)},e}();function Gn(t,e){if(t.length!==e.length)return!1;for(var r=0;r<t.length;r++)if(!Kn(t[r].attrs,e[r].attrs))return!1;return!0}function Kn(t,e,r){var i=t["STABLE-RENDITION-ID"];return i&&!r?i===e["STABLE-RENDITION-ID"]:!(r||["LANGUAGE","NAME","CHARACTERISTICS","AUTOSELECT","DEFAULT","FORCED","ASSOC-LANGUAGE"]).some((function(r){return t[r]!==e[r]}))}function Hn(t,e){return e.label.toLowerCase()===t.name.toLowerCase()&&(!e.language||e.language.toLowerCase()===(t.lang||"").toLowerCase())}var Vn=function(t){function e(e,r,i){var n;return(n=t.call(this,e,r,i,"[audio-stream-controller]",we)||this).videoBuffer=null,n.videoTrackCC=-1,n.waitingVideoCC=-1,n.bufferedTrack=null,n.switchingTrack=null,n.trackId=-1,n.waitingData=null,n.mainDetails=null,n.flushing=!1,n.bufferFlushed=!1,n.cachedTrackLoadedData=null,n._registerListeners(),n}l(e,t);var r=e.prototype;return r.onHandlerDestroying=function(){this._unregisterListeners(),t.prototype.onHandlerDestroying.call(this),this.mainDetails=null,this.bufferedTrack=null,this.switchingTrack=null},r._registerListeners=function(){var t=this.hls;t.on(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.on(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.LEVEL_LOADED,this.onLevelLoaded,this),t.on(S.AUDIO_TRACKS_UPDATED,this.onAudioTracksUpdated,this),t.on(S.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),t.on(S.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),t.on(S.ERROR,this.onError,this),t.on(S.BUFFER_RESET,this.onBufferReset,this),t.on(S.BUFFER_CREATED,this.onBufferCreated,this),t.on(S.BUFFER_FLUSHING,this.onBufferFlushing,this),t.on(S.BUFFER_FLUSHED,this.onBufferFlushed,this),t.on(S.INIT_PTS_FOUND,this.onInitPtsFound,this),t.on(S.FRAG_BUFFERED,this.onFragBuffered,this)},r._unregisterListeners=function(){var t=this.hls;t.off(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.off(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.LEVEL_LOADED,this.onLevelLoaded,this),t.off(S.AUDIO_TRACKS_UPDATED,this.onAudioTracksUpdated,this),t.off(S.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),t.off(S.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),t.off(S.ERROR,this.onError,this),t.off(S.BUFFER_RESET,this.onBufferReset,this),t.off(S.BUFFER_CREATED,this.onBufferCreated,this),t.off(S.BUFFER_FLUSHING,this.onBufferFlushing,this),t.off(S.BUFFER_FLUSHED,this.onBufferFlushed,this),t.off(S.INIT_PTS_FOUND,this.onInitPtsFound,this),t.off(S.FRAG_BUFFERED,this.onFragBuffered,this)},r.onInitPtsFound=function(t,e){var r=e.frag,i=e.id,n=e.initPTS,a=e.timescale;if("main"===i){var s=r.cc;this.initPTS[r.cc]={baseTime:n,timescale:a},this.log("InitPTS for cc: "+s+" found from main: "+n),this.videoTrackCC=s,this.state===Li&&this.tick()}},r.startLoad=function(t){if(!this.levels)return this.startPosition=t,void(this.state=ci);var e=this.lastCurrentTime;this.stopLoad(),this.setInterval(100),e>0&&-1===t?(this.log("Override startPosition with lastCurrentTime @"+e.toFixed(3)),t=e,this.state=fi):(this.loadedmetadata=!1,this.state=pi),this.nextLoadPosition=this.startPosition=this.lastCurrentTime=t,this.tick()},r.doTick=function(){switch(this.state){case fi:this.doTickIdle();break;case pi:var e,r=this.levels,i=this.trackId,n=null==r||null==(e=r[i])?void 0:e.details;if(n){if(this.waitForCdnTuneIn(n))break;this.state=Li}break;case mi:var a,s=performance.now(),o=this.retryDate;if(!o||s>=o||null!=(a=this.media)&&a.seeking){var l=this.levels,u=this.trackId;this.log("RetryDate reached, switch back to IDLE state"),this.resetStartWhenNotLoaded((null==l?void 0:l[u])||null),this.state=fi}break;case Li:var h=this.waitingData;if(h){var d=h.frag,c=h.part,f=h.cache,g=h.complete;if(void 0!==this.initPTS[d.cc]){this.waitingData=null,this.waitingVideoCC=-1,this.state=vi;var v={frag:d,part:c,payload:f.flush(),networkDetails:null};this._handleFragmentLoadProgress(v),g&&t.prototype._handleFragmentLoadComplete.call(this,v)}else if(this.videoTrackCC!==this.waitingVideoCC)this.log("Waiting fragment cc ("+d.cc+") cancelled because video is at cc "+this.videoTrackCC),this.clearWaitingFragment();else{var m=this.getLoadPosition(),p=zr.bufferInfo(this.mediaBuffer,m,this.config.maxBufferHole);pr(p.end,this.config.maxFragLookUpTolerance,d)<0&&(this.log("Waiting fragment cc ("+d.cc+") @ "+d.start+" cancelled because another fragment at "+p.end+" is needed"),this.clearWaitingFragment())}}else this.state=fi}this.onTickEnd()},r.clearWaitingFragment=function(){var t=this.waitingData;t&&(this.fragmentTracker.removeFragment(t.frag),this.waitingData=null,this.waitingVideoCC=-1,this.state=fi)},r.resetLoadingState=function(){this.clearWaitingFragment(),t.prototype.resetLoadingState.call(this)},r.onTickEnd=function(){var t=this.media;null!=t&&t.readyState&&(this.lastCurrentTime=t.currentTime)},r.doTickIdle=function(){var t=this.hls,e=this.levels,r=this.media,i=this.trackId,n=t.config;if((r||!this.startFragRequested&&n.startFragPrefetch)&&null!=e&&e[i]){var a=e[i],s=a.details;if(!s||s.live&&this.levelLastLoaded!==a||this.waitForCdnTuneIn(s))this.state=pi;else{var o=this.mediaBuffer?this.mediaBuffer:this.media;this.bufferFlushed&&o&&(this.bufferFlushed=!1,this.afterBufferFlushed(o,O,we));var l=this.getFwdBufferInfo(o,we);if(null!==l){var u=this.bufferedTrack,h=this.switchingTrack;if(!h&&this._streamEnded(l,s))return t.trigger(S.BUFFER_EOS,{type:"audio"}),void(this.state=Ti);var d=this.getFwdBufferInfo(this.videoBuffer?this.videoBuffer:this.media,Ie),c=l.len,f=this.getMaxBufferLength(null==d?void 0:d.len),g=s.fragments,v=g[0].start,m=this.flushing?this.getLoadPosition():l.end;if(h&&r){var p=this.getLoadPosition();u&&!Kn(h.attrs,u.attrs)&&(m=p),s.PTSKnown&&p<v&&(l.end>v||l.nextStart)&&(this.log("Alt audio track ahead of main track, seek to start of alt audio track"),r.currentTime=v+.05)}if(!(c>=f&&!h&&m<g[g.length-1].start)){var y=this.getNextFragment(m,s),E=!1;if(y&&this.isLoopLoading(y,m)&&(E=!!y.gap,y=this.getNextFragmentLoopLoading(y,s,l,Ie,f)),y){var T=d&&y.start>d.end+s.targetduration;if(T||(null==d||!d.len)&&l.len){var L=this.getAppendedFrag(y.start,Ie);if(null===L)return;if(E||(E=!!L.gap||!!T&&0===d.len),T&&!E||E&&l.nextStart&&l.nextStart<L.end)return}this.loadFragment(y,a,m)}else this.bufferFlushed=!0}}}}},r.getMaxBufferLength=function(e){var r=t.prototype.getMaxBufferLength.call(this);return e?Math.min(Math.max(r,e),this.config.maxMaxBufferLength):r},r.onMediaDetaching=function(){this.videoBuffer=null,this.bufferFlushed=this.flushing=!1,t.prototype.onMediaDetaching.call(this)},r.onAudioTracksUpdated=function(t,e){var r=e.audioTracks;this.resetTransmuxer(),this.levels=r.map((function(t){return new tr(t)}))},r.onAudioTrackSwitching=function(t,e){var r=!!e.url;this.trackId=e.id;var i=this.fragCurrent;i&&(i.abortRequests(),this.removeUnbufferedFrags(i.start)),this.resetLoadingState(),r?this.setInterval(100):this.resetTransmuxer(),r?(this.switchingTrack=e,this.state=fi,this.flushAudioIfNeeded(e)):(this.switchingTrack=null,this.bufferedTrack=e,this.state=ci),this.tick()},r.onManifestLoading=function(){this.fragmentTracker.removeAllFragments(),this.startPosition=this.lastCurrentTime=0,this.bufferFlushed=this.flushing=!1,this.levels=this.mainDetails=this.waitingData=this.bufferedTrack=this.cachedTrackLoadedData=this.switchingTrack=null,this.startFragRequested=!1,this.trackId=this.videoTrackCC=this.waitingVideoCC=-1},r.onLevelLoaded=function(t,e){this.mainDetails=e.details,null!==this.cachedTrackLoadedData&&(this.hls.trigger(S.AUDIO_TRACK_LOADED,this.cachedTrackLoadedData),this.cachedTrackLoadedData=null)},r.onAudioTrackLoaded=function(t,e){var r;if(null!=this.mainDetails){var i=this.levels,n=e.details,a=e.id;if(i){this.log("Audio track "+a+" loaded ["+n.startSN+","+n.endSN+"]"+(n.lastPartSn?"[part-"+n.lastPartSn+"-"+n.lastPartIndex+"]":"")+",duration:"+n.totalduration);var s=i[a],o=0;if(n.live||null!=(r=s.details)&&r.live){this.checkLiveUpdate(n);var l,u=this.mainDetails;if(n.deltaUpdateFailed||!u)return;!s.details&&n.hasProgramDateTime&&u.hasProgramDateTime?(ei(n,u),o=n.fragments[0].start):o=this.alignPlaylists(n,s.details,null==(l=this.levelLastLoaded)?void 0:l.details)}s.details=n,this.levelLastLoaded=s,this.startFragRequested||!this.mainDetails&&n.live||this.setStartPosition(s.details,o),this.state!==pi||this.waitForCdnTuneIn(n)||(this.state=fi),this.tick()}else this.warn("Audio tracks were reset while loading level "+a)}else this.cachedTrackLoadedData=e},r._handleFragmentLoadProgress=function(t){var e,r=t.frag,i=t.part,n=t.payload,a=this.config,s=this.trackId,o=this.levels;if(o){var l=o[s];if(l){var u=l.details;if(!u)return this.warn("Audio track details undefined on fragment load progress"),void this.removeUnbufferedFrags(r.start);var h=a.defaultAudioCodec||l.audioCodec||"mp4a.40.2",d=this.transmuxer;d||(d=this.transmuxer=new Bn(this.hls,we,this._handleTransmuxComplete.bind(this),this._handleTransmuxerFlush.bind(this)));var c=this.initPTS[r.cc],f=null==(e=r.initSegment)?void 0:e.data;if(void 0!==c){var g=i?i.index:-1,v=-1!==g,m=new Qr(r.level,r.sn,r.stats.chunkCount,n.byteLength,g,v);d.push(n,f,h,"",r,i,u.totalduration,!1,m,c)}else this.log("Unknown video PTS for cc "+r.cc+", waiting for video PTS before demuxing audio frag "+r.sn+" of ["+u.startSN+" ,"+u.endSN+"],track "+s),(this.waitingData=this.waitingData||{frag:r,part:i,cache:new ki,complete:!1}).cache.push(new Uint8Array(n)),this.waitingVideoCC=this.videoTrackCC,this.state=Li}else this.warn("Audio track is undefined on fragment load progress")}else this.warn("Audio tracks were reset while fragment load was in progress. Fragment "+r.sn+" of level "+r.level+" will not be buffered")},r._handleFragmentLoadComplete=function(e){this.waitingData?this.waitingData.complete=!0:t.prototype._handleFragmentLoadComplete.call(this,e)},r.onBufferReset=function(){this.mediaBuffer=this.videoBuffer=null,this.loadedmetadata=!1},r.onBufferCreated=function(t,e){var r=e.tracks.audio;r&&(this.mediaBuffer=r.buffer||null),e.tracks.video&&(this.videoBuffer=e.tracks.video.buffer||null)},r.onFragBuffered=function(t,e){var r=e.frag,n=e.part;if(r.type===we)if(this.fragContextChanged(r))this.warn("Fragment "+r.sn+(n?" p: "+n.index:"")+" of level "+r.level+" finished buffering, but was aborted. state: "+this.state+", audioSwitch: "+(this.switchingTrack?this.switchingTrack.name:"false"));else{if("initSegment"!==r.sn){this.fragPrevious=r;var a=this.switchingTrack;a&&(this.bufferedTrack=a,this.switchingTrack=null,this.hls.trigger(S.AUDIO_TRACK_SWITCHED,i({},a)))}this.fragBufferedComplete(r,n)}else if(!this.loadedmetadata&&r.type===Ie){var s=this.videoBuffer||this.media;s&&zr.getBuffered(s).length&&(this.loadedmetadata=!0)}},r.onError=function(e,r){var i;if(r.fatal)this.state=Si;else switch(r.details){case A.FRAG_GAP:case A.FRAG_PARSING_ERROR:case A.FRAG_DECRYPT_ERROR:case A.FRAG_LOAD_ERROR:case A.FRAG_LOAD_TIMEOUT:case A.KEY_LOAD_ERROR:case A.KEY_LOAD_TIMEOUT:this.onFragmentOrKeyLoadError(we,r);break;case A.AUDIO_TRACK_LOAD_ERROR:case A.AUDIO_TRACK_LOAD_TIMEOUT:case A.LEVEL_PARSING_ERROR:r.levelRetry||this.state!==pi||(null==(i=r.context)?void 0:i.type)!==be||(this.state=fi);break;case A.BUFFER_APPEND_ERROR:case A.BUFFER_FULL_ERROR:if(!r.parent||"audio"!==r.parent)return;if(r.details===A.BUFFER_APPEND_ERROR)return void this.resetLoadingState();this.reduceLengthAndFlushBuffer(r)&&(this.bufferedTrack=null,t.prototype.flushMainBuffer.call(this,0,Number.POSITIVE_INFINITY,"audio"));break;case A.INTERNAL_EXCEPTION:this.recoverWorkerError(r)}},r.onBufferFlushing=function(t,e){e.type!==N&&(this.flushing=!0)},r.onBufferFlushed=function(t,e){var r=e.type;if(r!==N){this.flushing=!1,this.bufferFlushed=!0,this.state===Ti&&(this.state=fi);var i=this.mediaBuffer||this.media;i&&(this.afterBufferFlushed(i,r,we),this.tick())}},r._handleTransmuxComplete=function(t){var e,r="audio",i=this.hls,n=t.remuxResult,a=t.chunkMeta,s=this.getCurrentContext(a);if(s){var l=s.frag,u=s.part,h=s.level,d=h.details,c=n.audio,f=n.text,g=n.id3,v=n.initSegment;if(!this.fragContextChanged(l)&&d){if(this.state=yi,this.switchingTrack&&c&&this.completeAudioSwitch(this.switchingTrack),null!=v&&v.tracks){var m=l.initSegment||l;this._bufferInitSegment(h,v.tracks,m,a),i.trigger(S.FRAG_PARSING_INIT_SEGMENT,{frag:m,id:r,tracks:v.tracks})}if(c){var p=c.startPTS,y=c.endPTS,E=c.startDTS,T=c.endDTS;u&&(u.elementaryStreams[O]={startPTS:p,endPTS:y,startDTS:E,endDTS:T}),l.setElementaryStreamInfo(O,p,y,E,T),this.bufferFragmentData(c,l,u,a)}if(null!=g&&null!=(e=g.samples)&&e.length){var L=o({id:r,frag:l,details:d},g);i.trigger(S.FRAG_PARSING_METADATA,L)}if(f){var A=o({id:r,frag:l,details:d},f);i.trigger(S.FRAG_PARSING_USERDATA,A)}}else this.fragmentTracker.removeFragment(l)}else this.resetWhenMissingContext(a)},r._bufferInitSegment=function(t,e,r,i){if(this.state===yi){e.video&&delete e.video;var n=e.audio;if(n){n.id="audio";var a=t.audioCodec;this.log("Init audio buffer, container:"+n.container+", codecs[level/parsed]=["+a+"/"+n.codec+"]"),a&&1===a.split(",").length&&(n.levelCodec=a),this.hls.trigger(S.BUFFER_CODECS,e);var s=n.initSegment;if(null!=s&&s.byteLength){var o={type:"audio",frag:r,part:null,chunkMeta:i,parent:r.type,data:s};this.hls.trigger(S.BUFFER_APPENDING,o)}this.tickImmediate()}}},r.loadFragment=function(e,r,i){var n,a=this.fragmentTracker.getState(e);if(this.fragCurrent=e,this.switchingTrack||a===Kr||a===Vr)if("initSegment"===e.sn)this._loadInitSegment(e,r);else if(null!=(n=r.details)&&n.live&&!this.initPTS[e.cc]){this.log("Waiting for video PTS in continuity counter "+e.cc+" of live stream before loading audio fragment "+e.sn+" of level "+this.trackId),this.state=Li;var s=this.mainDetails;s&&s.fragments[0].start!==r.details.fragments[0].start&&ei(r.details,s)}else this.startFragRequested=!0,t.prototype.loadFragment.call(this,e,r,i);else this.clearTrackerIfNeeded(e)},r.flushAudioIfNeeded=function(e){var r=this.media,i=this.bufferedTrack,n=null==i?void 0:i.attrs,a=e.attrs;r&&n&&(n.CHANNELS!==a.CHANNELS||i.name!==e.name||i.lang!==e.lang)&&(this.log("Switching audio track : flushing all audio"),t.prototype.flushMainBuffer.call(this,0,Number.POSITIVE_INFINITY,"audio"),this.bufferedTrack=null)},r.completeAudioSwitch=function(t){var e=this.hls;this.flushAudioIfNeeded(t),this.bufferedTrack=t,this.switchingTrack=null,e.trigger(S.AUDIO_TRACK_SWITCHED,i({},t))},e}(Ri),Yn=function(t){function e(e){var r;return(r=t.call(this,e,"[audio-track-controller]")||this).tracks=[],r.groupIds=null,r.tracksInGroup=[],r.trackId=-1,r.currentTrack=null,r.selectDefaultTrack=!0,r.registerListeners(),r}l(e,t);var r=e.prototype;return r.registerListeners=function(){var t=this.hls;t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.MANIFEST_PARSED,this.onManifestParsed,this),t.on(S.LEVEL_LOADING,this.onLevelLoading,this),t.on(S.LEVEL_SWITCHING,this.onLevelSwitching,this),t.on(S.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),t.on(S.ERROR,this.onError,this)},r.unregisterListeners=function(){var t=this.hls;t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.MANIFEST_PARSED,this.onManifestParsed,this),t.off(S.LEVEL_LOADING,this.onLevelLoading,this),t.off(S.LEVEL_SWITCHING,this.onLevelSwitching,this),t.off(S.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),t.off(S.ERROR,this.onError,this)},r.destroy=function(){this.unregisterListeners(),this.tracks.length=0,this.tracksInGroup.length=0,this.currentTrack=null,t.prototype.destroy.call(this)},r.onManifestLoading=function(){this.tracks=[],this.tracksInGroup=[],this.groupIds=null,this.currentTrack=null,this.trackId=-1,this.selectDefaultTrack=!0},r.onManifestParsed=function(t,e){this.tracks=e.audioTracks||[]},r.onAudioTrackLoaded=function(t,e){var r=e.id,i=e.groupId,n=e.details,a=this.tracksInGroup[r];if(a&&a.groupId===i){var s=a.details;a.details=e.details,this.log("Audio track "+r+' "'+a.name+'" lang:'+a.lang+" group:"+i+" loaded ["+n.startSN+"-"+n.endSN+"]"),r===this.trackId&&this.playlistLoaded(r,e,s)}else this.warn("Audio track with id:"+r+" and group:"+i+" not found in active group "+(null==a?void 0:a.groupId))},r.onLevelLoading=function(t,e){this.switchLevel(e.level)},r.onLevelSwitching=function(t,e){this.switchLevel(e.level)},r.switchLevel=function(t){var e=this.hls.levels[t];if(e){var r=e.audioGroups||null,i=this.groupIds,n=this.currentTrack;if(!r||(null==i?void 0:i.length)!==(null==r?void 0:r.length)||null!=r&&r.some((function(t){return-1===(null==i?void 0:i.indexOf(t))}))){this.groupIds=r,this.trackId=-1,this.currentTrack=null;var a=this.tracks.filter((function(t){return!r||-1!==r.indexOf(t.groupId)}));if(a.length)this.selectDefaultTrack&&!a.some((function(t){return t.default}))&&(this.selectDefaultTrack=!1),a.forEach((function(t,e){t.id=e}));else if(!n&&!this.tracksInGroup.length)return;this.tracksInGroup=a;var s=this.hls.config.audioPreference;if(!n&&s){var o=Mr(s,a,Nr);if(o>-1)n=a[o];else{var l=Mr(s,this.tracks);n=this.tracks[l]}}var u=this.findTrackId(n);-1===u&&n&&(u=this.findTrackId(null));var h={audioTracks:a};this.log("Updating audio tracks, "+a.length+" track(s) found in group(s): "+(null==r?void 0:r.join(","))),this.hls.trigger(S.AUDIO_TRACKS_UPDATED,h);var d=this.trackId;if(-1!==u&&-1===d)this.setAudioTrack(u);else if(a.length&&-1===d){var c,f=new Error("No audio track selected for current audio group-ID(s): "+(null==(c=this.groupIds)?void 0:c.join(","))+" track count: "+a.length);this.warn(f.message),this.hls.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.AUDIO_TRACK_LOAD_ERROR,fatal:!0,error:f})}}else this.shouldReloadPlaylist(n)&&this.setAudioTrack(this.trackId)}},r.onError=function(t,e){!e.fatal&&e.context&&(e.context.type!==be||e.context.id!==this.trackId||this.groupIds&&-1===this.groupIds.indexOf(e.context.groupId)||(this.requestScheduled=-1,this.checkRetry(e)))},r.setAudioOption=function(t){var e=this.hls;if(e.config.audioPreference=t,t){var r=this.allAudioTracks;if(this.selectDefaultTrack=!1,r.length){var i=this.currentTrack;if(i&&Or(t,i,Nr))return i;var n=Mr(t,this.tracksInGroup,Nr);if(n>-1){var a=this.tracksInGroup[n];return this.setAudioTrack(n),a}if(i){var s=e.loadLevel;-1===s&&(s=e.firstAutoLevel);var o=function(t,e,r,i,n){var a=e[i],s=e.reduce((function(t,e,r){var i=e.uri;return(t[i]||(t[i]=[])).push(r),t}),{})[a.uri];s.length>1&&(i=Math.max.apply(Math,s));var o=a.videoRange,l=a.frameRate,u=a.codecSet.substring(0,4),h=Ur(e,i,(function(e){if(e.videoRange!==o||e.frameRate!==l||e.codecSet.substring(0,4)!==u)return!1;var i=e.audioGroups,a=r.filter((function(t){return!i||-1!==i.indexOf(t.groupId)}));return Mr(t,a,n)>-1}));return h>-1?h:Ur(e,i,(function(e){var i=e.audioGroups,a=r.filter((function(t){return!i||-1!==i.indexOf(t.groupId)}));return Mr(t,a,n)>-1}))}(t,e.levels,r,s,Nr);if(-1===o)return null;e.nextLoadLevel=o}if(t.channels||t.audioCodec){var l=Mr(t,r);if(l>-1)return r[l]}}}return null},r.setAudioTrack=function(t){var e=this.tracksInGroup;if(t<0||t>=e.length)this.warn("Invalid audio track id: "+t);else{this.clearTimer(),this.selectDefaultTrack=!1;var r=this.currentTrack,n=e[t],a=n.details&&!n.details.live;if(!(t===this.trackId&&n===r&&a||(this.log("Switching to audio-track "+t+' "'+n.name+'" lang:'+n.lang+" group:"+n.groupId+" channels:"+n.channels),this.trackId=t,this.currentTrack=n,this.hls.trigger(S.AUDIO_TRACK_SWITCHING,i({},n)),a))){var s=this.switchParams(n.url,null==r?void 0:r.details);this.loadPlaylist(s)}}},r.findTrackId=function(t){for(var e=this.tracksInGroup,r=0;r<e.length;r++){var i=e[r];if((!this.selectDefaultTrack||i.default)&&(!t||Or(t,i,Nr)))return r}if(t){for(var n=t.name,a=t.lang,s=t.assocLang,o=t.characteristics,l=t.audioCodec,u=t.channels,h=0;h<e.length;h++)if(Or({name:n,lang:a,assocLang:s,characteristics:o,audioCodec:l,channels:u},e[h],Nr))return h;for(var d=0;d<e.length;d++){var c=e[d];if(Kn(t.attrs,c.attrs,["LANGUAGE","ASSOC-LANGUAGE","CHARACTERISTICS"]))return d}for(var f=0;f<e.length;f++){var g=e[f];if(Kn(t.attrs,g.attrs,["LANGUAGE"]))return f}}return-1},r.loadPlaylist=function(e){var r=this.currentTrack;if(this.shouldLoadPlaylist(r)&&r){t.prototype.loadPlaylist.call(this);var i=r.id,n=r.groupId,a=r.url;if(e)try{a=e.addDirectives(a)}catch(t){this.warn("Could not construct new URL with HLS Delivery Directives: "+t)}this.log("loading audio-track playlist "+i+' "'+r.name+'" lang:'+r.lang+" group:"+n),this.clearTimer(),this.hls.trigger(S.AUDIO_TRACK_LOADING,{url:a,id:i,groupId:n,deliveryDirectives:e||null})}},s(e,[{key:"allAudioTracks",get:function(){return this.tracks}},{key:"audioTracks",get:function(){return this.tracksInGroup}},{key:"audioTrack",get:function(){return this.trackId},set:function(t){this.selectDefaultTrack=!1,this.setAudioTrack(t)}}]),e}(Dr),Wn=function(t){function e(e,r,i){var n;return(n=t.call(this,e,r,i,"[subtitle-stream-controller]",Ce)||this).currentTrackId=-1,n.tracksBuffered=[],n.mainDetails=null,n._registerListeners(),n}l(e,t);var r=e.prototype;return r.onHandlerDestroying=function(){this._unregisterListeners(),t.prototype.onHandlerDestroying.call(this),this.mainDetails=null},r._registerListeners=function(){var t=this.hls;t.on(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.on(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.LEVEL_LOADED,this.onLevelLoaded,this),t.on(S.ERROR,this.onError,this),t.on(S.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),t.on(S.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),t.on(S.SUBTITLE_TRACK_LOADED,this.onSubtitleTrackLoaded,this),t.on(S.SUBTITLE_FRAG_PROCESSED,this.onSubtitleFragProcessed,this),t.on(S.BUFFER_FLUSHING,this.onBufferFlushing,this),t.on(S.FRAG_BUFFERED,this.onFragBuffered,this)},r._unregisterListeners=function(){var t=this.hls;t.off(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.off(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.LEVEL_LOADED,this.onLevelLoaded,this),t.off(S.ERROR,this.onError,this),t.off(S.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),t.off(S.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),t.off(S.SUBTITLE_TRACK_LOADED,this.onSubtitleTrackLoaded,this),t.off(S.SUBTITLE_FRAG_PROCESSED,this.onSubtitleFragProcessed,this),t.off(S.BUFFER_FLUSHING,this.onBufferFlushing,this),t.off(S.FRAG_BUFFERED,this.onFragBuffered,this)},r.startLoad=function(t){this.stopLoad(),this.state=fi,this.setInterval(500),this.nextLoadPosition=this.startPosition=this.lastCurrentTime=t,this.tick()},r.onManifestLoading=function(){this.mainDetails=null,this.fragmentTracker.removeAllFragments()},r.onMediaDetaching=function(){this.tracksBuffered=[],t.prototype.onMediaDetaching.call(this)},r.onLevelLoaded=function(t,e){this.mainDetails=e.details},r.onSubtitleFragProcessed=function(t,e){var r=e.frag,i=e.success;if(this.fragPrevious=r,this.state=fi,i){var n=this.tracksBuffered[this.currentTrackId];if(n){for(var a,s=r.start,o=0;o<n.length;o++)if(s>=n[o].start&&s<=n[o].end){a=n[o];break}var l=r.start+r.duration;a?a.end=l:(a={start:s,end:l},n.push(a)),this.fragmentTracker.fragBuffered(r),this.fragBufferedComplete(r,null)}}},r.onBufferFlushing=function(t,e){var r=e.startOffset,i=e.endOffset;if(0===r&&i!==Number.POSITIVE_INFINITY){var n=i-1;if(n<=0)return;e.endOffsetSubtitles=Math.max(0,n),this.tracksBuffered.forEach((function(t){for(var e=0;e<t.length;)if(t[e].end<=n)t.shift();else{if(!(t[e].start<n))break;t[e].start=n,e++}})),this.fragmentTracker.removeFragmentsInRange(r,n,Ce)}},r.onFragBuffered=function(t,e){var r;this.loadedmetadata||e.frag.type!==Ie||null!=(r=this.media)&&r.buffered.length&&(this.loadedmetadata=!0)},r.onError=function(t,e){var r=e.frag;(null==r?void 0:r.type)===Ce&&(this.fragCurrent&&this.fragCurrent.abortRequests(),this.state!==ci&&(this.state=fi))},r.onSubtitleTracksUpdated=function(t,e){var r=this,i=e.subtitleTracks;this.levels&&!Gn(this.levels,i)?(this.tracksBuffered=[],this.levels=i.map((function(t){var e=new tr(t);return r.tracksBuffered[e.id]=[],e})),this.fragmentTracker.removeFragmentsInRange(0,Number.POSITIVE_INFINITY,Ce),this.fragPrevious=null,this.mediaBuffer=null):this.levels=i.map((function(t){return new tr(t)}))},r.onSubtitleTrackSwitch=function(t,e){var r;if(this.currentTrackId=e.id,null!=(r=this.levels)&&r.length&&-1!==this.currentTrackId){var i=this.levels[this.currentTrackId];null!=i&&i.details?this.mediaBuffer=this.mediaBufferTimeRanges:this.mediaBuffer=null,i&&this.setInterval(500)}else this.clearInterval()},r.onSubtitleTrackLoaded=function(t,e){var r,i=this.currentTrackId,n=this.levels,a=e.details,s=e.id;if(n){var o=n[i];if(!(s>=n.length||s!==i)&&o){this.log("Subtitle track "+s+" loaded ["+a.startSN+","+a.endSN+"]"+(a.lastPartSn?"[part-"+a.lastPartSn+"-"+a.lastPartIndex+"]":"")+",duration:"+a.totalduration),this.mediaBuffer=this.mediaBufferTimeRanges;var l=0;if(a.live||null!=(r=o.details)&&r.live){var u=this.mainDetails;if(a.deltaUpdateFailed||!u)return;var h,d=u.fragments[0];o.details?0===(l=this.alignPlaylists(a,o.details,null==(h=this.levelLastLoaded)?void 0:h.details))&&d&&sr(a,l=d.start):a.hasProgramDateTime&&u.hasProgramDateTime?(ei(a,u),l=a.fragments[0].start):d&&sr(a,l=d.start)}o.details=a,this.levelLastLoaded=o,this.startFragRequested||!this.mainDetails&&a.live||this.setStartPosition(o.details,l),this.tick(),a.live&&!this.fragCurrent&&this.media&&this.state===fi&&(mr(null,a.fragments,this.media.currentTime,0)||(this.warn("Subtitle playlist not aligned with playback"),o.details=void 0))}}else this.warn("Subtitle tracks were reset while loading level "+s)},r._handleFragmentLoadComplete=function(t){var e=this,r=t.frag,i=t.payload,n=r.decryptdata,a=this.hls;if(!this.fragContextChanged(r)&&i&&i.byteLength>0&&null!=n&&n.key&&n.iv&&"AES-128"===n.method){var s=performance.now();this.decrypter.decrypt(new Uint8Array(i),n.key.buffer,n.iv.buffer).catch((function(t){throw a.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.FRAG_DECRYPT_ERROR,fatal:!1,error:t,reason:t.message,frag:r}),t})).then((function(t){var e=performance.now();a.trigger(S.FRAG_DECRYPTED,{frag:r,payload:t,stats:{tstart:s,tdecrypt:e}})})).catch((function(t){e.warn(t.name+": "+t.message),e.state=fi}))}},r.doTick=function(){if(this.media){if(this.state===fi){var t=this.currentTrackId,e=this.levels,r=null==e?void 0:e[t];if(!r||!e.length||!r.details)return;var i=this.config,n=this.getLoadPosition(),a=zr.bufferedInfo(this.tracksBuffered[this.currentTrackId]||[],n,i.maxBufferHole),s=a.end,o=a.len,l=this.getFwdBufferInfo(this.media,Ie),u=r.details;if(o>this.getMaxBufferLength(null==l?void 0:l.len)+u.levelTargetDuration)return;var h=u.fragments,d=h.length,c=u.edge,f=null,g=this.fragPrevious;if(s<c){var v=i.maxFragLookUpTolerance,m=s>c-v?0:v;!(f=mr(g,h,Math.max(h[0].start,s),m))&&g&&g.start<h[0].start&&(f=h[0])}else f=h[d-1];if(!f)return;if("initSegment"!==(f=this.mapToInitFragWhenRequired(f)).sn){var p=h[f.sn-u.startSN-1];p&&p.cc===f.cc&&this.fragmentTracker.getState(p)===Kr&&(f=p)}this.fragmentTracker.getState(f)===Kr&&this.loadFragment(f,r,s)}}else this.state=fi},r.getMaxBufferLength=function(e){var r=t.prototype.getMaxBufferLength.call(this);return e?Math.max(r,e):r},r.loadFragment=function(e,r,i){this.fragCurrent=e,"initSegment"===e.sn?this._loadInitSegment(e,r):(this.startFragRequested=!0,t.prototype.loadFragment.call(this,e,r,i))},s(e,[{key:"mediaBufferTimeRanges",get:function(){return new jn(this.tracksBuffered[this.currentTrackId]||[])}}]),e}(Ri),jn=function(t){this.buffered=void 0;var e=function(e,r,i){if((r>>>=0)>i-1)throw new DOMException("Failed to execute '"+e+"' on 'TimeRanges': The index provided ("+r+") is greater than the maximum bound ("+i+")");return t[r][e]};this.buffered={get length(){return t.length},end:function(r){return e("end",r,t.length)},start:function(r){return e("start",r,t.length)}}},qn=function(t){function e(e){var r;return(r=t.call(this,e,"[subtitle-track-controller]")||this).media=null,r.tracks=[],r.groupIds=null,r.tracksInGroup=[],r.trackId=-1,r.currentTrack=null,r.selectDefaultTrack=!0,r.queuedDefaultTrack=-1,r.asyncPollTrackChange=function(){return r.pollTrackChange(0)},r.useTextTrackPolling=!1,r.subtitlePollingInterval=-1,r._subtitleDisplay=!0,r.onTextTracksChanged=function(){if(r.useTextTrackPolling||self.clearInterval(r.subtitlePollingInterval),r.media&&r.hls.config.renderTextTracksNatively){for(var t=null,e=Ue(r.media.textTracks),i=0;i<e.length;i++)if("hidden"===e[i].mode)t=e[i];else if("showing"===e[i].mode){t=e[i];break}var n=r.findTrackForTextTrack(t);r.subtitleTrack!==n&&r.setSubtitleTrack(n)}},r.registerListeners(),r}l(e,t);var r=e.prototype;return r.destroy=function(){this.unregisterListeners(),this.tracks.length=0,this.tracksInGroup.length=0,this.currentTrack=null,this.onTextTracksChanged=this.asyncPollTrackChange=null,t.prototype.destroy.call(this)},r.registerListeners=function(){var t=this.hls;t.on(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.on(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.MANIFEST_PARSED,this.onManifestParsed,this),t.on(S.LEVEL_LOADING,this.onLevelLoading,this),t.on(S.LEVEL_SWITCHING,this.onLevelSwitching,this),t.on(S.SUBTITLE_TRACK_LOADED,this.onSubtitleTrackLoaded,this),t.on(S.ERROR,this.onError,this)},r.unregisterListeners=function(){var t=this.hls;t.off(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.off(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.MANIFEST_PARSED,this.onManifestParsed,this),t.off(S.LEVEL_LOADING,this.onLevelLoading,this),t.off(S.LEVEL_SWITCHING,this.onLevelSwitching,this),t.off(S.SUBTITLE_TRACK_LOADED,this.onSubtitleTrackLoaded,this),t.off(S.ERROR,this.onError,this)},r.onMediaAttached=function(t,e){this.media=e.media,this.media&&(this.queuedDefaultTrack>-1&&(this.subtitleTrack=this.queuedDefaultTrack,this.queuedDefaultTrack=-1),this.useTextTrackPolling=!(this.media.textTracks&&"onchange"in this.media.textTracks),this.useTextTrackPolling?this.pollTrackChange(500):this.media.textTracks.addEventListener("change",this.asyncPollTrackChange))},r.pollTrackChange=function(t){self.clearInterval(this.subtitlePollingInterval),this.subtitlePollingInterval=self.setInterval(this.onTextTracksChanged,t)},r.onMediaDetaching=function(){this.media&&(self.clearInterval(this.subtitlePollingInterval),this.useTextTrackPolling||this.media.textTracks.removeEventListener("change",this.asyncPollTrackChange),this.trackId>-1&&(this.queuedDefaultTrack=this.trackId),Ue(this.media.textTracks).forEach((function(t){Oe(t)})),this.subtitleTrack=-1,this.media=null)},r.onManifestLoading=function(){this.tracks=[],this.groupIds=null,this.tracksInGroup=[],this.trackId=-1,this.currentTrack=null,this.selectDefaultTrack=!0},r.onManifestParsed=function(t,e){this.tracks=e.subtitleTracks},r.onSubtitleTrackLoaded=function(t,e){var r=e.id,i=e.groupId,n=e.details,a=this.tracksInGroup[r];if(a&&a.groupId===i){var s=a.details;a.details=e.details,this.log("Subtitle track "+r+' "'+a.name+'" lang:'+a.lang+" group:"+i+" loaded ["+n.startSN+"-"+n.endSN+"]"),r===this.trackId&&this.playlistLoaded(r,e,s)}else this.warn("Subtitle track with id:"+r+" and group:"+i+" not found in active group "+(null==a?void 0:a.groupId))},r.onLevelLoading=function(t,e){this.switchLevel(e.level)},r.onLevelSwitching=function(t,e){this.switchLevel(e.level)},r.switchLevel=function(t){var e=this.hls.levels[t];if(e){var r=e.subtitleGroups||null,i=this.groupIds,n=this.currentTrack;if(!r||(null==i?void 0:i.length)!==(null==r?void 0:r.length)||null!=r&&r.some((function(t){return-1===(null==i?void 0:i.indexOf(t))}))){this.groupIds=r,this.trackId=-1,this.currentTrack=null;var a=this.tracks.filter((function(t){return!r||-1!==r.indexOf(t.groupId)}));if(a.length)this.selectDefaultTrack&&!a.some((function(t){return t.default}))&&(this.selectDefaultTrack=!1),a.forEach((function(t,e){t.id=e}));else if(!n&&!this.tracksInGroup.length)return;this.tracksInGroup=a;var s=this.hls.config.subtitlePreference;if(!n&&s){this.selectDefaultTrack=!1;var o=Mr(s,a);if(o>-1)n=a[o];else{var l=Mr(s,this.tracks);n=this.tracks[l]}}var u=this.findTrackId(n);-1===u&&n&&(u=this.findTrackId(null));var h={subtitleTracks:a};this.log("Updating subtitle tracks, "+a.length+' track(s) found in "'+(null==r?void 0:r.join(","))+'" group-id'),this.hls.trigger(S.SUBTITLE_TRACKS_UPDATED,h),-1!==u&&-1===this.trackId&&this.setSubtitleTrack(u)}else this.shouldReloadPlaylist(n)&&this.setSubtitleTrack(this.trackId)}},r.findTrackId=function(t){for(var e=this.tracksInGroup,r=this.selectDefaultTrack,i=0;i<e.length;i++){var n=e[i];if((!r||n.default)&&(r||t)&&(!t||Or(n,t)))return i}if(t){for(var a=0;a<e.length;a++){var s=e[a];if(Kn(t.attrs,s.attrs,["LANGUAGE","ASSOC-LANGUAGE","CHARACTERISTICS"]))return a}for(var o=0;o<e.length;o++){var l=e[o];if(Kn(t.attrs,l.attrs,["LANGUAGE"]))return o}}return-1},r.findTrackForTextTrack=function(t){if(t)for(var e=this.tracksInGroup,r=0;r<e.length;r++)if(Hn(e[r],t))return r;return-1},r.onError=function(t,e){!e.fatal&&e.context&&(e.context.type!==De||e.context.id!==this.trackId||this.groupIds&&-1===this.groupIds.indexOf(e.context.groupId)||this.checkRetry(e))},r.setSubtitleOption=function(t){if(this.hls.config.subtitlePreference=t,t){var e=this.allSubtitleTracks;if(this.selectDefaultTrack=!1,e.length){var r=this.currentTrack;if(r&&Or(t,r))return r;var i=Mr(t,this.tracksInGroup);if(i>-1){var n=this.tracksInGroup[i];return this.setSubtitleTrack(i),n}if(r)return null;var a=Mr(t,e);if(a>-1)return e[a]}}return null},r.loadPlaylist=function(e){t.prototype.loadPlaylist.call(this);var r=this.currentTrack;if(this.shouldLoadPlaylist(r)&&r){var i=r.id,n=r.groupId,a=r.url;if(e)try{a=e.addDirectives(a)}catch(t){this.warn("Could not construct new URL with HLS Delivery Directives: "+t)}this.log("Loading subtitle playlist for id "+i),this.hls.trigger(S.SUBTITLE_TRACK_LOADING,{url:a,id:i,groupId:n,deliveryDirectives:e||null})}},r.toggleTrackModes=function(){var t=this.media;if(t){var e,r=Ue(t.textTracks),i=this.currentTrack;if(i&&((e=r.filter((function(t){return Hn(i,t)}))[0])||this.warn('Unable to find subtitle TextTrack with name "'+i.name+'" and language "'+i.lang+'"')),[].slice.call(r).forEach((function(t){"disabled"!==t.mode&&t!==e&&(t.mode="disabled")})),e){var n=this.subtitleDisplay?"showing":"hidden";e.mode!==n&&(e.mode=n)}}},r.setSubtitleTrack=function(t){var e=this.tracksInGroup;if(this.media)if(t<-1||t>=e.length||!y(t))this.warn("Invalid subtitle track id: "+t);else{this.clearTimer(),this.selectDefaultTrack=!1;var r=this.currentTrack,i=e[t]||null;if(this.trackId=t,this.currentTrack=i,this.toggleTrackModes(),i){var n=!!i.details&&!i.details.live;if(t!==this.trackId||i!==r||!n){this.log("Switching to subtitle-track "+t+(i?' "'+i.name+'" lang:'+i.lang+" group:"+i.groupId:""));var a=i.id,s=i.groupId,o=void 0===s?"":s,l=i.name,u=i.type,h=i.url;this.hls.trigger(S.SUBTITLE_TRACK_SWITCH,{id:a,groupId:o,name:l,type:u,url:h});var d=this.switchParams(i.url,null==r?void 0:r.details);this.loadPlaylist(d)}}else this.hls.trigger(S.SUBTITLE_TRACK_SWITCH,{id:t})}else this.queuedDefaultTrack=t},s(e,[{key:"subtitleDisplay",get:function(){return this._subtitleDisplay},set:function(t){this._subtitleDisplay=t,this.trackId>-1&&this.toggleTrackModes()}},{key:"allSubtitleTracks",get:function(){return this.tracks}},{key:"subtitleTracks",get:function(){return this.tracksInGroup}},{key:"subtitleTrack",get:function(){return this.trackId},set:function(t){this.selectDefaultTrack=!1,this.setSubtitleTrack(t)}}]),e}(Dr),Xn=function(){function t(t){this.buffers=void 0,this.queues={video:[],audio:[],audiovideo:[]},this.buffers=t}var e=t.prototype;return e.append=function(t,e,r){var i=this.queues[e];i.push(t),1!==i.length||r||this.executeNext(e)},e.insertAbort=function(t,e){this.queues[e].unshift(t),this.executeNext(e)},e.appendBlocker=function(t){var e,r=new Promise((function(t){e=t})),i={execute:e,onStart:function(){},onComplete:function(){},onError:function(){}};return this.append(i,t),r},e.executeNext=function(t){var e=this.queues[t];if(e.length){var r=e[0];try{r.execute()}catch(e){w.warn('[buffer-operation-queue]: Exception executing "'+t+'" SourceBuffer operation: '+e),r.onError(e);var i=this.buffers[t];null!=i&&i.updating||this.shiftAndExecuteNext(t)}}},e.shiftAndExecuteNext=function(t){this.queues[t].shift(),this.executeNext(t)},e.current=function(t){return this.queues[t][0]},t}(),zn=/(avc[1234]|hvc1|hev1|dvh[1e]|vp09|av01)(?:\.[^.,]+)+/,Qn=function(){function t(t){var e=this;this.details=null,this._objectUrl=null,this.operationQueue=void 0,this.listeners=void 0,this.hls=void 0,this.bufferCodecEventsExpected=0,this._bufferCodecEventsTotal=0,this.media=null,this.mediaSource=null,this.lastMpegAudioChunk=null,this.appendSource=void 0,this.appendErrors={audio:0,video:0,audiovideo:0},this.tracks={},this.pendingTracks={},this.sourceBuffer=void 0,this.log=void 0,this.warn=void 0,this.error=void 0,this._onEndStreaming=function(t){e.hls&&e.hls.pauseBuffering()},this._onStartStreaming=function(t){e.hls&&e.hls.resumeBuffering()},this._onMediaSourceOpen=function(){var t=e.media,r=e.mediaSource;e.log("Media source opened"),t&&(t.removeEventListener("emptied",e._onMediaEmptied),e.updateMediaElementDuration(),e.hls.trigger(S.MEDIA_ATTACHED,{media:t,mediaSource:r})),r&&r.removeEventListener("sourceopen",e._onMediaSourceOpen),e.checkPendingTracks()},this._onMediaSourceClose=function(){e.log("Media source closed")},this._onMediaSourceEnded=function(){e.log("Media source ended")},this._onMediaEmptied=function(){var t=e.mediaSrc,r=e._objectUrl;t!==r&&w.error("Media element src was set while attaching MediaSource ("+r+" > "+t+")")},this.hls=t;var r="[buffer-controller]";this.appendSource=t.config.preferManagedMediaSource,this.log=w.log.bind(w,r),this.warn=w.warn.bind(w,r),this.error=w.error.bind(w,r),this._initSourceBuffer(),this.registerListeners()}var e=t.prototype;return e.hasSourceTypes=function(){return this.getSourceBufferTypes().length>0||Object.keys(this.pendingTracks).length>0},e.destroy=function(){this.unregisterListeners(),this.details=null,this.lastMpegAudioChunk=null,this.hls=null},e.registerListeners=function(){var t=this.hls;t.on(S.MEDIA_ATTACHING,this.onMediaAttaching,this),t.on(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.MANIFEST_PARSED,this.onManifestParsed,this),t.on(S.BUFFER_RESET,this.onBufferReset,this),t.on(S.BUFFER_APPENDING,this.onBufferAppending,this),t.on(S.BUFFER_CODECS,this.onBufferCodecs,this),t.on(S.BUFFER_EOS,this.onBufferEos,this),t.on(S.BUFFER_FLUSHING,this.onBufferFlushing,this),t.on(S.LEVEL_UPDATED,this.onLevelUpdated,this),t.on(S.FRAG_PARSED,this.onFragParsed,this),t.on(S.FRAG_CHANGED,this.onFragChanged,this)},e.unregisterListeners=function(){var t=this.hls;t.off(S.MEDIA_ATTACHING,this.onMediaAttaching,this),t.off(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.MANIFEST_PARSED,this.onManifestParsed,this),t.off(S.BUFFER_RESET,this.onBufferReset,this),t.off(S.BUFFER_APPENDING,this.onBufferAppending,this),t.off(S.BUFFER_CODECS,this.onBufferCodecs,this),t.off(S.BUFFER_EOS,this.onBufferEos,this),t.off(S.BUFFER_FLUSHING,this.onBufferFlushing,this),t.off(S.LEVEL_UPDATED,this.onLevelUpdated,this),t.off(S.FRAG_PARSED,this.onFragParsed,this),t.off(S.FRAG_CHANGED,this.onFragChanged,this)},e._initSourceBuffer=function(){this.sourceBuffer={},this.operationQueue=new Xn(this.sourceBuffer),this.listeners={audio:[],video:[],audiovideo:[]},this.appendErrors={audio:0,video:0,audiovideo:0},this.lastMpegAudioChunk=null},e.onManifestLoading=function(){this.bufferCodecEventsExpected=this._bufferCodecEventsTotal=0,this.details=null},e.onManifestParsed=function(t,e){var r=2;(e.audio&&!e.video||!e.altAudio)&&(r=1),this.bufferCodecEventsExpected=this._bufferCodecEventsTotal=r,this.log(this.bufferCodecEventsExpected+" bufferCodec event(s) expected")},e.onMediaAttaching=function(t,e){var r=this.media=e.media,i=te(this.appendSource);if(r&&i){var n,a=this.mediaSource=new i;this.log("created media source: "+(null==(n=a.constructor)?void 0:n.name)),a.addEventListener("sourceopen",this._onMediaSourceOpen),a.addEventListener("sourceended",this._onMediaSourceEnded),a.addEventListener("sourceclose",this._onMediaSourceClose),a.addEventListener("startstreaming",this._onStartStreaming),a.addEventListener("endstreaming",this._onEndStreaming);var s=this._objectUrl=self.URL.createObjectURL(a);if(this.appendSource)try{r.removeAttribute("src");var o=self.ManagedMediaSource;r.disableRemotePlayback=r.disableRemotePlayback||o&&a instanceof o,Jn(r),function(t,e){var r=self.document.createElement("source");r.type="video/mp4",r.src=e,t.appendChild(r)}(r,s),r.load()}catch(t){r.src=s}else r.src=s;r.addEventListener("emptied",this._onMediaEmptied)}},e.onMediaDetaching=function(){var t=this.media,e=this.mediaSource,r=this._objectUrl;if(e){if(this.log("media source detaching"),"open"===e.readyState)try{e.endOfStream()}catch(t){this.warn("onMediaDetaching: "+t.message+" while calling endOfStream")}this.onBufferReset(),e.removeEventListener("sourceopen",this._onMediaSourceOpen),e.removeEventListener("sourceended",this._onMediaSourceEnded),e.removeEventListener("sourceclose",this._onMediaSourceClose),e.removeEventListener("startstreaming",this._onStartStreaming),e.removeEventListener("endstreaming",this._onEndStreaming),t&&(t.removeEventListener("emptied",this._onMediaEmptied),r&&self.URL.revokeObjectURL(r),this.mediaSrc===r?(t.removeAttribute("src"),this.appendSource&&Jn(t),t.load()):this.warn("media|source.src was changed by a third party - skip cleanup")),this.mediaSource=null,this.media=null,this._objectUrl=null,this.bufferCodecEventsExpected=this._bufferCodecEventsTotal,this.pendingTracks={},this.tracks={}}this.hls.trigger(S.MEDIA_DETACHED,void 0)},e.onBufferReset=function(){var t=this;this.getSourceBufferTypes().forEach((function(e){t.resetBuffer(e)})),this._initSourceBuffer()},e.resetBuffer=function(t){var e=this.sourceBuffer[t];try{var r;e&&(this.removeBufferListeners(t),this.sourceBuffer[t]=void 0,null!=(r=this.mediaSource)&&r.sourceBuffers.length&&this.mediaSource.removeSourceBuffer(e))}catch(e){this.warn("onBufferReset "+t,e)}},e.onBufferCodecs=function(t,e){var r=this,i=this.getSourceBufferTypes().length,n=Object.keys(e);if(n.forEach((function(t){if(i){var n=r.tracks[t];if(n&&"function"==typeof n.buffer.changeType){var a,s=e[t],o=s.id,l=s.codec,u=s.levelCodec,h=s.container,d=s.metadata,c=he(n.codec,n.levelCodec),f=null==c?void 0:c.replace(zn,"$1"),g=he(l,u),v=null==(a=g)?void 0:a.replace(zn,"$1");if(g&&f!==v){"audio"===t.slice(0,5)&&(g=ue(g,r.hls.config.preferManagedMediaSource));var m=h+";codecs="+g;r.appendChangeType(t,m),r.log("switching codec "+c+" to "+g),r.tracks[t]={buffer:n.buffer,codec:l,container:h,levelCodec:u,metadata:d,id:o}}}}else r.pendingTracks[t]=e[t]})),!i){var a=Math.max(this.bufferCodecEventsExpected-1,0);this.bufferCodecEventsExpected!==a&&(this.log(a+" bufferCodec event(s) expected "+n.join(",")),this.bufferCodecEventsExpected=a),this.mediaSource&&"open"===this.mediaSource.readyState&&this.checkPendingTracks()}},e.appendChangeType=function(t,e){var r=this,i=this.operationQueue,n={execute:function(){var n=r.sourceBuffer[t];n&&(r.log("changing "+t+" sourceBuffer type to "+e),n.changeType(e)),i.shiftAndExecuteNext(t)},onStart:function(){},onComplete:function(){},onError:function(e){r.warn("Failed to change "+t+" SourceBuffer type",e)}};i.append(n,t,!!this.pendingTracks[t])},e.onBufferAppending=function(t,e){var r=this,i=this.hls,n=this.operationQueue,a=this.tracks,s=e.data,o=e.type,l=e.frag,u=e.part,h=e.chunkMeta,d=h.buffering[o],c=self.performance.now();d.start=c;var f=l.stats.buffering,g=u?u.stats.buffering:null;0===f.start&&(f.start=c),g&&0===g.start&&(g.start=c);var v=a.audio,m=!1;"audio"===o&&"audio/mpeg"===(null==v?void 0:v.container)&&(m=!this.lastMpegAudioChunk||1===h.id||this.lastMpegAudioChunk.sn!==h.sn,this.lastMpegAudioChunk=h);var p=l.start,y={execute:function(){if(d.executeStart=self.performance.now(),m){var t=r.sourceBuffer[o];if(t){var e=p-t.timestampOffset;Math.abs(e)>=.1&&(r.log("Updating audio SourceBuffer timestampOffset to "+p+" (delta: "+e+") sn: "+l.sn+")"),t.timestampOffset=p)}}r.appendExecutor(s,o)},onStart:function(){},onComplete:function(){var t=self.performance.now();d.executeEnd=d.end=t,0===f.first&&(f.first=t),g&&0===g.first&&(g.first=t);var e=r.sourceBuffer,i={};for(var n in e)i[n]=zr.getBuffered(e[n]);r.appendErrors[o]=0,"audio"===o||"video"===o?r.appendErrors.audiovideo=0:(r.appendErrors.audio=0,r.appendErrors.video=0),r.hls.trigger(S.BUFFER_APPENDED,{type:o,frag:l,part:u,chunkMeta:h,parent:l.type,timeRanges:i})},onError:function(t){var e={type:L.MEDIA_ERROR,parent:l.type,details:A.BUFFER_APPEND_ERROR,sourceBufferName:o,frag:l,part:u,chunkMeta:h,error:t,err:t,fatal:!1};if(t.code===DOMException.QUOTA_EXCEEDED_ERR)e.details=A.BUFFER_FULL_ERROR;else{var n=++r.appendErrors[o];e.details=A.BUFFER_APPEND_ERROR,r.warn("Failed "+n+"/"+i.config.appendErrorMaxRetry+' times to append segment in "'+o+'" sourceBuffer'),n>=i.config.appendErrorMaxRetry&&(e.fatal=!0)}i.trigger(S.ERROR,e)}};n.append(y,o,!!this.pendingTracks[o])},e.onBufferFlushing=function(t,e){var r=this,i=this.operationQueue,n=function(t){return{execute:r.removeExecutor.bind(r,t,e.startOffset,e.endOffset),onStart:function(){},onComplete:function(){r.hls.trigger(S.BUFFER_FLUSHED,{type:t})},onError:function(e){r.warn("Failed to remove from "+t+" SourceBuffer",e)}}};e.type?i.append(n(e.type),e.type):this.getSourceBufferTypes().forEach((function(t){i.append(n(t),t)}))},e.onFragParsed=function(t,e){var r=this,i=e.frag,n=e.part,a=[],s=n?n.elementaryStreams:i.elementaryStreams;s[U]?a.push("audiovideo"):(s[O]&&a.push("audio"),s[N]&&a.push("video")),0===a.length&&this.warn("Fragments must have at least one ElementaryStreamType set. type: "+i.type+" level: "+i.level+" sn: "+i.sn),this.blockBuffers((function(){var t=self.performance.now();i.stats.buffering.end=t,n&&(n.stats.buffering.end=t);var e=n?n.stats:i.stats;r.hls.trigger(S.FRAG_BUFFERED,{frag:i,part:n,stats:e,id:i.type})}),a)},e.onFragChanged=function(t,e){this.trimBuffers()},e.onBufferEos=function(t,e){var r=this;this.getSourceBufferTypes().reduce((function(t,i){var n=r.sourceBuffer[i];return!n||e.type&&e.type!==i||(n.ending=!0,n.ended||(n.ended=!0,r.log(i+" sourceBuffer now EOS"))),t&&!(n&&!n.ended)}),!0)&&(this.log("Queueing mediaSource.endOfStream()"),this.blockBuffers((function(){r.getSourceBufferTypes().forEach((function(t){var e=r.sourceBuffer[t];e&&(e.ending=!1)}));var t=r.mediaSource;t&&"open"===t.readyState?(r.log("Calling mediaSource.endOfStream()"),t.endOfStream()):t&&r.log("Could not call mediaSource.endOfStream(). mediaSource.readyState: "+t.readyState)})))},e.onLevelUpdated=function(t,e){var r=e.details;r.fragments.length&&(this.details=r,this.getSourceBufferTypes().length?this.blockBuffers(this.updateMediaElementDuration.bind(this)):this.updateMediaElementDuration())},e.trimBuffers=function(){var t=this.hls,e=this.details,r=this.media;if(r&&null!==e&&this.getSourceBufferTypes().length){var i=t.config,n=r.currentTime,a=e.levelTargetDuration,s=e.live&&null!==i.liveBackBufferLength?i.liveBackBufferLength:i.backBufferLength;if(y(s)&&s>0){var o=Math.max(s,a),l=Math.floor(n/a)*a-o;this.flushBackBuffer(n,a,l)}if(y(i.frontBufferFlushThreshold)&&i.frontBufferFlushThreshold>0){var u=Math.max(i.maxBufferLength,i.frontBufferFlushThreshold),h=Math.max(u,a),d=Math.floor(n/a)*a+h;this.flushFrontBuffer(n,a,d)}}},e.flushBackBuffer=function(t,e,r){var i=this,n=this.details,a=this.sourceBuffer;this.getSourceBufferTypes().forEach((function(s){var o=a[s];if(o){var l=zr.getBuffered(o);if(l.length>0&&r>l.start(0)){if(i.hls.trigger(S.BACK_BUFFER_REACHED,{bufferEnd:r}),null!=n&&n.live)i.hls.trigger(S.LIVE_BACK_BUFFER_REACHED,{bufferEnd:r});else if(o.ended&&l.end(l.length-1)-t<2*e)return void i.log("Cannot flush "+s+" back buffer while SourceBuffer is in ended state");i.hls.trigger(S.BUFFER_FLUSHING,{startOffset:0,endOffset:r,type:s})}}}))},e.flushFrontBuffer=function(t,e,r){var i=this,n=this.sourceBuffer;this.getSourceBufferTypes().forEach((function(a){var s=n[a];if(s){var o=zr.getBuffered(s),l=o.length;if(l<2)return;var u=o.start(l-1),h=o.end(l-1);if(r>u||t>=u&&t<=h)return;if(s.ended&&t-h<2*e)return void i.log("Cannot flush "+a+" front buffer while SourceBuffer is in ended state");i.hls.trigger(S.BUFFER_FLUSHING,{startOffset:u,endOffset:1/0,type:a})}}))},e.updateMediaElementDuration=function(){if(this.details&&this.media&&this.mediaSource&&"open"===this.mediaSource.readyState){var t=this.details,e=this.hls,r=this.media,i=this.mediaSource,n=t.fragments[0].start+t.totalduration,a=r.duration,s=y(i.duration)?i.duration:0;t.live&&e.config.liveDurationInfinity?(i.duration=1/0,this.updateSeekableRange(t)):(n>s&&n>a||!y(a))&&(this.log("Updating Media Source duration to "+n.toFixed(3)),i.duration=n)}},e.updateSeekableRange=function(t){var e=this.mediaSource,r=t.fragments;if(r.length&&t.live&&null!=e&&e.setLiveSeekableRange){var i=Math.max(0,r[0].start),n=Math.max(i,i+t.totalduration);this.log("Media Source duration is set to "+e.duration+". Setting seekable range to "+i+"-"+n+"."),e.setLiveSeekableRange(i,n)}},e.checkPendingTracks=function(){var t=this.bufferCodecEventsExpected,e=this.operationQueue,r=this.pendingTracks,i=Object.keys(r).length;if(i&&(!t||2===i||"audiovideo"in r)){this.createSourceBuffers(r),this.pendingTracks={};var n=this.getSourceBufferTypes();if(n.length)this.hls.trigger(S.BUFFER_CREATED,{tracks:this.tracks}),n.forEach((function(t){e.executeNext(t)}));else{var a=new Error("could not create source buffer for media codec(s)");this.hls.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.BUFFER_INCOMPATIBLE_CODECS_ERROR,fatal:!0,error:a,reason:a.message})}}},e.createSourceBuffers=function(t){var e=this,r=this.sourceBuffer,i=this.mediaSource;if(!i)throw Error("createSourceBuffers called when mediaSource was null");var n=function(n){if(!r[n]){var a=t[n];if(!a)throw Error("source buffer exists for track "+n+", however track does not");var s=a.levelCodec||a.codec;s&&"audio"===n.slice(0,5)&&(s=ue(s,e.hls.config.preferManagedMediaSource));var o=a.container+";codecs="+s;e.log("creating sourceBuffer("+o+")");try{var l=r[n]=i.addSourceBuffer(o),u=n;e.addBufferListener(u,"updatestart",e._onSBUpdateStart),e.addBufferListener(u,"updateend",e._onSBUpdateEnd),e.addBufferListener(u,"error",e._onSBUpdateError),e.addBufferListener(u,"bufferedchange",(function(t,r){var i=r.removedRanges;null!=i&&i.length&&e.hls.trigger(S.BUFFER_FLUSHED,{type:n})})),e.tracks[n]={buffer:l,codec:s,container:a.container,levelCodec:a.levelCodec,metadata:a.metadata,id:a.id}}catch(t){e.error("error while trying to add sourceBuffer: "+t.message),e.hls.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.BUFFER_ADD_CODEC_ERROR,fatal:!1,error:t,sourceBufferName:n,mimeType:o})}}};for(var a in t)n(a)},e._onSBUpdateStart=function(t){this.operationQueue.current(t).onStart()},e._onSBUpdateEnd=function(t){var e;if("closed"!==(null==(e=this.mediaSource)?void 0:e.readyState)){var r=this.operationQueue;r.current(t).onComplete(),r.shiftAndExecuteNext(t)}else this.resetBuffer(t)},e._onSBUpdateError=function(t,e){var r,i=new Error(t+" SourceBuffer error. MediaSource readyState: "+(null==(r=this.mediaSource)?void 0:r.readyState));this.error(""+i,e),this.hls.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.BUFFER_APPENDING_ERROR,sourceBufferName:t,error:i,fatal:!1});var n=this.operationQueue.current(t);n&&n.onError(i)},e.removeExecutor=function(t,e,r){var i=this.media,n=this.mediaSource,a=this.operationQueue,s=this.sourceBuffer[t];if(!i||!n||!s)return this.warn("Attempting to remove from the "+t+" SourceBuffer, but it does not exist"),void a.shiftAndExecuteNext(t);var o=y(i.duration)?i.duration:1/0,l=y(n.duration)?n.duration:1/0,u=Math.max(0,e),h=Math.min(r,o,l);h>u&&(!s.ending||s.ended)?(s.ended=!1,this.log("Removing ["+u+","+h+"] from the "+t+" SourceBuffer"),s.remove(u,h)):a.shiftAndExecuteNext(t)},e.appendExecutor=function(t,e){var r=this.sourceBuffer[e];if(r)r.ended=!1,r.appendBuffer(t);else if(!this.pendingTracks[e])throw new Error("Attempting to append to the "+e+" SourceBuffer, but it does not exist")},e.blockBuffers=function(t,e){var r=this;if(void 0===e&&(e=this.getSourceBufferTypes()),!e.length)return this.log("Blocking operation requested, but no SourceBuffers exist"),void Promise.resolve().then(t);var i=this.operationQueue,n=e.map((function(t){return i.appendBlocker(t)}));Promise.all(n).then((function(){t(),e.forEach((function(t){var e=r.sourceBuffer[t];null!=e&&e.updating||i.shiftAndExecuteNext(t)}))}))},e.getSourceBufferTypes=function(){return Object.keys(this.sourceBuffer)},e.addBufferListener=function(t,e,r){var i=this.sourceBuffer[t];if(i){var n=r.bind(this,t);this.listeners[t].push({event:e,listener:n}),i.addEventListener(e,n)}},e.removeBufferListeners=function(t){var e=this.sourceBuffer[t];e&&this.listeners[t].forEach((function(t){e.removeEventListener(t.event,t.listener)}))},s(t,[{key:"mediaSrc",get:function(){var t,e=(null==(t=this.media)?void 0:t.firstChild)||this.media;return null==e?void 0:e.src}}]),t}();function Jn(t){var e=t.querySelectorAll("source");[].slice.call(e).forEach((function(e){t.removeChild(e)}))}var $n={42:225,92:233,94:237,95:243,96:250,123:231,124:247,125:209,126:241,127:9608,128:174,129:176,130:189,131:191,132:8482,133:162,134:163,135:9834,136:224,137:32,138:232,139:226,140:234,141:238,142:244,143:251,144:193,145:201,146:211,147:218,148:220,149:252,150:8216,151:161,152:42,153:8217,154:9473,155:169,156:8480,157:8226,158:8220,159:8221,160:192,161:194,162:199,163:200,164:202,165:203,166:235,167:206,168:207,169:239,170:212,171:217,172:249,173:219,174:171,175:187,176:195,177:227,178:205,179:204,180:236,181:210,182:242,183:213,184:245,185:123,186:125,187:92,188:94,189:95,190:124,191:8764,192:196,193:228,194:214,195:246,196:223,197:165,198:164,199:9475,200:197,201:229,202:216,203:248,204:9487,205:9491,206:9495,207:9499},Zn=function(t){var e=t;return $n.hasOwnProperty(t)&&(e=$n[t]),String.fromCharCode(e)},ta=15,ea=100,ra={17:1,18:3,21:5,22:7,23:9,16:11,19:12,20:14},ia={17:2,18:4,21:6,22:8,23:10,19:13,20:15},na={25:1,26:3,29:5,30:7,31:9,24:11,27:12,28:14},aa={25:2,26:4,29:6,30:8,31:10,27:13,28:15},sa=["white","green","blue","cyan","red","yellow","magenta","black","transparent"],oa=function(){function t(){this.time=null,this.verboseLevel=0}return t.prototype.log=function(t,e){if(this.verboseLevel>=t){var r="function"==typeof e?e():e;w.log(this.time+" ["+t+"] "+r)}},t}(),la=function(t){for(var e=[],r=0;r<t.length;r++)e.push(t[r].toString(16));return e},ua=function(){function t(){this.foreground="white",this.underline=!1,this.italics=!1,this.background="black",this.flash=!1}var e=t.prototype;return e.reset=function(){this.foreground="white",this.underline=!1,this.italics=!1,this.background="black",this.flash=!1},e.setStyles=function(t){for(var e=["foreground","underline","italics","background","flash"],r=0;r<e.length;r++){var i=e[r];t.hasOwnProperty(i)&&(this[i]=t[i])}},e.isDefault=function(){return"white"===this.foreground&&!this.underline&&!this.italics&&"black"===this.background&&!this.flash},e.equals=function(t){return this.foreground===t.foreground&&this.underline===t.underline&&this.italics===t.italics&&this.background===t.background&&this.flash===t.flash},e.copy=function(t){this.foreground=t.foreground,this.underline=t.underline,this.italics=t.italics,this.background=t.background,this.flash=t.flash},e.toString=function(){return"color="+this.foreground+", underline="+this.underline+", italics="+this.italics+", background="+this.background+", flash="+this.flash},t}(),ha=function(){function t(){this.uchar=" ",this.penState=new ua}var e=t.prototype;return e.reset=function(){this.uchar=" ",this.penState.reset()},e.setChar=function(t,e){this.uchar=t,this.penState.copy(e)},e.setPenState=function(t){this.penState.copy(t)},e.equals=function(t){return this.uchar===t.uchar&&this.penState.equals(t.penState)},e.copy=function(t){this.uchar=t.uchar,this.penState.copy(t.penState)},e.isEmpty=function(){return" "===this.uchar&&this.penState.isDefault()},t}(),da=function(){function t(t){this.chars=[],this.pos=0,this.currPenState=new ua,this.cueStartTime=null,this.logger=void 0;for(var e=0;e<ea;e++)this.chars.push(new ha);this.logger=t}var e=t.prototype;return e.equals=function(t){for(var e=0;e<ea;e++)if(!this.chars[e].equals(t.chars[e]))return!1;return!0},e.copy=function(t){for(var e=0;e<ea;e++)this.chars[e].copy(t.chars[e])},e.isEmpty=function(){for(var t=!0,e=0;e<ea;e++)if(!this.chars[e].isEmpty()){t=!1;break}return t},e.setCursor=function(t){this.pos!==t&&(this.pos=t),this.pos<0?(this.logger.log(3,"Negative cursor position "+this.pos),this.pos=0):this.pos>ea&&(this.logger.log(3,"Too large cursor position "+this.pos),this.pos=ea)},e.moveCursor=function(t){var e=this.pos+t;if(t>1)for(var r=this.pos+1;r<e+1;r++)this.chars[r].setPenState(this.currPenState);this.setCursor(e)},e.backSpace=function(){this.moveCursor(-1),this.chars[this.pos].setChar(" ",this.currPenState)},e.insertChar=function(t){var e=this;t>=144&&this.backSpace();var r=Zn(t);this.pos>=ea?this.logger.log(0,(function(){return"Cannot insert "+t.toString(16)+" ("+r+") at position "+e.pos+". Skipping it!"})):(this.chars[this.pos].setChar(r,this.currPenState),this.moveCursor(1))},e.clearFromPos=function(t){var e;for(e=t;e<ea;e++)this.chars[e].reset()},e.clear=function(){this.clearFromPos(0),this.pos=0,this.currPenState.reset()},e.clearToEndOfRow=function(){this.clearFromPos(this.pos)},e.getTextString=function(){for(var t=[],e=!0,r=0;r<ea;r++){var i=this.chars[r].uchar;" "!==i&&(e=!1),t.push(i)}return e?"":t.join("")},e.setPenStyles=function(t){this.currPenState.setStyles(t),this.chars[this.pos].setPenState(this.currPenState)},t}(),ca=function(){function t(t){this.rows=[],this.currRow=14,this.nrRollUpRows=null,this.lastOutputScreen=null,this.logger=void 0;for(var e=0;e<ta;e++)this.rows.push(new da(t));this.logger=t}var e=t.prototype;return e.reset=function(){for(var t=0;t<ta;t++)this.rows[t].clear();this.currRow=14},e.equals=function(t){for(var e=!0,r=0;r<ta;r++)if(!this.rows[r].equals(t.rows[r])){e=!1;break}return e},e.copy=function(t){for(var e=0;e<ta;e++)this.rows[e].copy(t.rows[e])},e.isEmpty=function(){for(var t=!0,e=0;e<ta;e++)if(!this.rows[e].isEmpty()){t=!1;break}return t},e.backSpace=function(){this.rows[this.currRow].backSpace()},e.clearToEndOfRow=function(){this.rows[this.currRow].clearToEndOfRow()},e.insertChar=function(t){this.rows[this.currRow].insertChar(t)},e.setPen=function(t){this.rows[this.currRow].setPenStyles(t)},e.moveCursor=function(t){this.rows[this.currRow].moveCursor(t)},e.setCursor=function(t){this.logger.log(2,"setCursor: "+t),this.rows[this.currRow].setCursor(t)},e.setPAC=function(t){this.logger.log(2,(function(){return"pacData = "+JSON.stringify(t)}));var e=t.row-1;if(this.nrRollUpRows&&e<this.nrRollUpRows-1&&(e=this.nrRollUpRows-1),this.nrRollUpRows&&this.currRow!==e){for(var r=0;r<ta;r++)this.rows[r].clear();var i=this.currRow+1-this.nrRollUpRows,n=this.lastOutputScreen;if(n){var a=n.rows[i].cueStartTime,s=this.logger.time;if(null!==a&&null!==s&&a<s)for(var o=0;o<this.nrRollUpRows;o++)this.rows[e-this.nrRollUpRows+o+1].copy(n.rows[i+o])}}this.currRow=e;var l=this.rows[this.currRow];if(null!==t.indent){var u=t.indent,h=Math.max(u-1,0);l.setCursor(t.indent),t.color=l.chars[h].penState.foreground}var d={foreground:t.color,underline:t.underline,italics:t.italics,background:"black",flash:!1};this.setPen(d)},e.setBkgData=function(t){this.logger.log(2,(function(){return"bkgData = "+JSON.stringify(t)})),this.backSpace(),this.setPen(t),this.insertChar(32)},e.setRollUpRows=function(t){this.nrRollUpRows=t},e.rollUp=function(){var t=this;if(null!==this.nrRollUpRows){this.logger.log(1,(function(){return t.getDisplayText()}));var e=this.currRow+1-this.nrRollUpRows,r=this.rows.splice(e,1)[0];r.clear(),this.rows.splice(this.currRow,0,r),this.logger.log(2,"Rolling up")}else this.logger.log(3,"roll_up but nrRollUpRows not set yet")},e.getDisplayText=function(t){t=t||!1;for(var e=[],r="",i=-1,n=0;n<ta;n++){var a=this.rows[n].getTextString();a&&(i=n+1,t?e.push("Row "+i+": '"+a+"'"):e.push(a.trim()))}return e.length>0&&(r=t?"["+e.join(" | ")+"]":e.join("\n")),r},e.getTextAndFormat=function(){return this.rows},t}(),fa=function(){function t(t,e,r){this.chNr=void 0,this.outputFilter=void 0,this.mode=void 0,this.verbose=void 0,this.displayedMemory=void 0,this.nonDisplayedMemory=void 0,this.lastOutputScreen=void 0,this.currRollUpRow=void 0,this.writeScreen=void 0,this.cueStartTime=void 0,this.logger=void 0,this.chNr=t,this.outputFilter=e,this.mode=null,this.verbose=0,this.displayedMemory=new ca(r),this.nonDisplayedMemory=new ca(r),this.lastOutputScreen=new ca(r),this.currRollUpRow=this.displayedMemory.rows[14],this.writeScreen=this.displayedMemory,this.mode=null,this.cueStartTime=null,this.logger=r}var e=t.prototype;return e.reset=function(){this.mode=null,this.displayedMemory.reset(),this.nonDisplayedMemory.reset(),this.lastOutputScreen.reset(),this.outputFilter.reset(),this.currRollUpRow=this.displayedMemory.rows[14],this.writeScreen=this.displayedMemory,this.mode=null,this.cueStartTime=null},e.getHandler=function(){return this.outputFilter},e.setHandler=function(t){this.outputFilter=t},e.setPAC=function(t){this.writeScreen.setPAC(t)},e.setBkgData=function(t){this.writeScreen.setBkgData(t)},e.setMode=function(t){t!==this.mode&&(this.mode=t,this.logger.log(2,(function(){return"MODE="+t})),"MODE_POP-ON"===this.mode?this.writeScreen=this.nonDisplayedMemory:(this.writeScreen=this.displayedMemory,this.writeScreen.reset()),"MODE_ROLL-UP"!==this.mode&&(this.displayedMemory.nrRollUpRows=null,this.nonDisplayedMemory.nrRollUpRows=null),this.mode=t)},e.insertChars=function(t){for(var e=this,r=0;r<t.length;r++)this.writeScreen.insertChar(t[r]);var i=this.writeScreen===this.displayedMemory?"DISP":"NON_DISP";this.logger.log(2,(function(){return i+": "+e.writeScreen.getDisplayText(!0)})),"MODE_PAINT-ON"!==this.mode&&"MODE_ROLL-UP"!==this.mode||(this.logger.log(1,(function(){return"DISPLAYED: "+e.displayedMemory.getDisplayText(!0)})),this.outputDataUpdate())},e.ccRCL=function(){this.logger.log(2,"RCL - Resume Caption Loading"),this.setMode("MODE_POP-ON")},e.ccBS=function(){this.logger.log(2,"BS - BackSpace"),"MODE_TEXT"!==this.mode&&(this.writeScreen.backSpace(),this.writeScreen===this.displayedMemory&&this.outputDataUpdate())},e.ccAOF=function(){},e.ccAON=function(){},e.ccDER=function(){this.logger.log(2,"DER- Delete to End of Row"),this.writeScreen.clearToEndOfRow(),this.outputDataUpdate()},e.ccRU=function(t){this.logger.log(2,"RU("+t+") - Roll Up"),this.writeScreen=this.displayedMemory,this.setMode("MODE_ROLL-UP"),this.writeScreen.setRollUpRows(t)},e.ccFON=function(){this.logger.log(2,"FON - Flash On"),this.writeScreen.setPen({flash:!0})},e.ccRDC=function(){this.logger.log(2,"RDC - Resume Direct Captioning"),this.setMode("MODE_PAINT-ON")},e.ccTR=function(){this.logger.log(2,"TR"),this.setMode("MODE_TEXT")},e.ccRTD=function(){this.logger.log(2,"RTD"),this.setMode("MODE_TEXT")},e.ccEDM=function(){this.logger.log(2,"EDM - Erase Displayed Memory"),this.displayedMemory.reset(),this.outputDataUpdate(!0)},e.ccCR=function(){this.logger.log(2,"CR - Carriage Return"),this.writeScreen.rollUp(),this.outputDataUpdate(!0)},e.ccENM=function(){this.logger.log(2,"ENM - Erase Non-displayed Memory"),this.nonDisplayedMemory.reset()},e.ccEOC=function(){var t=this;if(this.logger.log(2,"EOC - End Of Caption"),"MODE_POP-ON"===this.mode){var e=this.displayedMemory;this.displayedMemory=this.nonDisplayedMemory,this.nonDisplayedMemory=e,this.writeScreen=this.nonDisplayedMemory,this.logger.log(1,(function(){return"DISP: "+t.displayedMemory.getDisplayText()}))}this.outputDataUpdate(!0)},e.ccTO=function(t){this.logger.log(2,"TO("+t+") - Tab Offset"),this.writeScreen.moveCursor(t)},e.ccMIDROW=function(t){var e={flash:!1};if(e.underline=t%2==1,e.italics=t>=46,e.italics)e.foreground="white";else{var r=Math.floor(t/2)-16;e.foreground=["white","green","blue","cyan","red","yellow","magenta"][r]}this.logger.log(2,"MIDROW: "+JSON.stringify(e)),this.writeScreen.setPen(e)},e.outputDataUpdate=function(t){void 0===t&&(t=!1);var e=this.logger.time;null!==e&&this.outputFilter&&(null!==this.cueStartTime||this.displayedMemory.isEmpty()?this.displayedMemory.equals(this.lastOutputScreen)||(this.outputFilter.newCue(this.cueStartTime,e,this.lastOutputScreen),t&&this.outputFilter.dispatchCue&&this.outputFilter.dispatchCue(),this.cueStartTime=this.displayedMemory.isEmpty()?null:e):this.cueStartTime=e,this.lastOutputScreen.copy(this.displayedMemory))},e.cueSplitAtTime=function(t){this.outputFilter&&(this.displayedMemory.isEmpty()||(this.outputFilter.newCue&&this.outputFilter.newCue(this.cueStartTime,t,this.displayedMemory),this.cueStartTime=t))},t}(),ga=function(){function t(t,e,r){this.channels=void 0,this.currentChannel=0,this.cmdHistory={a:null,b:null},this.logger=void 0;var i=this.logger=new oa;this.channels=[null,new fa(t,e,i),new fa(t+1,r,i)]}var e=t.prototype;return e.getHandler=function(t){return this.channels[t].getHandler()},e.setHandler=function(t,e){this.channels[t].setHandler(e)},e.addData=function(t,e){var r,i,n,a=!1;this.logger.time=t;for(var s=0;s<e.length;s+=2)if(i=127&e[s],n=127&e[s+1],0!==i||0!==n){if(this.logger.log(3,"["+la([e[s],e[s+1]])+"] -> ("+la([i,n])+")"),(r=this.parseCmd(i,n))||(r=this.parseMidrow(i,n)),r||(r=this.parsePAC(i,n)),r||(r=this.parseBackgroundAttributes(i,n)),!r&&(a=this.parseChars(i,n))){var o=this.currentChannel;o&&o>0?this.channels[o].insertChars(a):this.logger.log(2,"No channel found yet. TEXT-MODE?")}r||a||this.logger.log(2,"Couldn't parse cleaned data "+la([i,n])+" orig: "+la([e[s],e[s+1]]))}},e.parseCmd=function(t,e){var r=this.cmdHistory;if(!((20===t||28===t||21===t||29===t)&&e>=32&&e<=47||(23===t||31===t)&&e>=33&&e<=35))return!1;if(ma(t,e,r))return va(null,null,r),this.logger.log(3,"Repeated command ("+la([t,e])+") is dropped"),!0;var i=20===t||21===t||23===t?1:2,n=this.channels[i];return 20===t||21===t||28===t||29===t?32===e?n.ccRCL():33===e?n.ccBS():34===e?n.ccAOF():35===e?n.ccAON():36===e?n.ccDER():37===e?n.ccRU(2):38===e?n.ccRU(3):39===e?n.ccRU(4):40===e?n.ccFON():41===e?n.ccRDC():42===e?n.ccTR():43===e?n.ccRTD():44===e?n.ccEDM():45===e?n.ccCR():46===e?n.ccENM():47===e&&n.ccEOC():n.ccTO(e-32),va(t,e,r),this.currentChannel=i,!0},e.parseMidrow=function(t,e){var r=0;if((17===t||25===t)&&e>=32&&e<=47){if((r=17===t?1:2)!==this.currentChannel)return this.logger.log(0,"Mismatch channel in midrow parsing"),!1;var i=this.channels[r];return!!i&&(i.ccMIDROW(e),this.logger.log(3,"MIDROW ("+la([t,e])+")"),!0)}return!1},e.parsePAC=function(t,e){var r,i=this.cmdHistory;if(!((t>=17&&t<=23||t>=25&&t<=31)&&e>=64&&e<=127||(16===t||24===t)&&e>=64&&e<=95))return!1;if(ma(t,e,i))return va(null,null,i),!0;var n=t<=23?1:2;r=e>=64&&e<=95?1===n?ra[t]:na[t]:1===n?ia[t]:aa[t];var a=this.channels[n];return!!a&&(a.setPAC(this.interpretPAC(r,e)),va(t,e,i),this.currentChannel=n,!0)},e.interpretPAC=function(t,e){var r,i={color:null,italics:!1,indent:null,underline:!1,row:t};return r=e>95?e-96:e-64,i.underline=1==(1&r),r<=13?i.color=["white","green","blue","cyan","red","yellow","magenta","white"][Math.floor(r/2)]:r<=15?(i.italics=!0,i.color="white"):i.indent=4*Math.floor((r-16)/2),i},e.parseChars=function(t,e){var r,i,n=null,a=null;if(t>=25?(r=2,a=t-8):(r=1,a=t),a>=17&&a<=19?(i=17===a?e+80:18===a?e+112:e+144,this.logger.log(2,"Special char '"+Zn(i)+"' in channel "+r),n=[i]):t>=32&&t<=127&&(n=0===e?[t]:[t,e]),n){var s=la(n);this.logger.log(3,"Char codes = "+s.join(",")),va(t,e,this.cmdHistory)}return n},e.parseBackgroundAttributes=function(t,e){var r;if(!((16===t||24===t)&&e>=32&&e<=47||(23===t||31===t)&&e>=45&&e<=47))return!1;var i={};16===t||24===t?(r=Math.floor((e-32)/2),i.background=sa[r],e%2==1&&(i.background=i.background+"_semi")):45===e?i.background="transparent":(i.foreground="black",47===e&&(i.underline=!0));var n=t<=23?1:2;return this.channels[n].setBkgData(i),va(t,e,this.cmdHistory),!0},e.reset=function(){for(var t=0;t<Object.keys(this.channels).length;t++){var e=this.channels[t];e&&e.reset()}this.cmdHistory={a:null,b:null}},e.cueSplitAtTime=function(t){for(var e=0;e<this.channels.length;e++){var r=this.channels[e];r&&r.cueSplitAtTime(t)}},t}();function va(t,e,r){r.a=t,r.b=e}function ma(t,e,r){return r.a===t&&r.b===e}var pa=function(){function t(t,e){this.timelineController=void 0,this.cueRanges=[],this.trackName=void 0,this.startTime=null,this.endTime=null,this.screen=null,this.timelineController=t,this.trackName=e}var e=t.prototype;return e.dispatchCue=function(){null!==this.startTime&&(this.timelineController.addCues(this.trackName,this.startTime,this.endTime,this.screen,this.cueRanges),this.startTime=null)},e.newCue=function(t,e,r){(null===this.startTime||this.startTime>t)&&(this.startTime=t),this.endTime=e,this.screen=r,this.timelineController.createCaptionsTrack(this.trackName)},e.reset=function(){this.cueRanges=[],this.startTime=null},t}(),ya=function(){if(null!=j&&j.VTTCue)return self.VTTCue;var t=["","lr","rl"],e=["start","middle","end","left","right"];function r(t,e){if("string"!=typeof e)return!1;if(!Array.isArray(t))return!1;var r=e.toLowerCase();return!!~t.indexOf(r)&&r}function i(t){return r(e,t)}function n(t){for(var e=arguments.length,r=new Array(e>1?e-1:0),i=1;i<e;i++)r[i-1]=arguments[i];for(var n=1;n<arguments.length;n++){var a=arguments[n];for(var s in a)t[s]=a[s]}return t}function a(e,a,s){var o=this,l={enumerable:!0};o.hasBeenReset=!1;var u="",h=!1,d=e,c=a,f=s,g=null,v="",m=!0,p="auto",y="start",E=50,T="middle",S=50,L="middle";Object.defineProperty(o,"id",n({},l,{get:function(){return u},set:function(t){u=""+t}})),Object.defineProperty(o,"pauseOnExit",n({},l,{get:function(){return h},set:function(t){h=!!t}})),Object.defineProperty(o,"startTime",n({},l,{get:function(){return d},set:function(t){if("number"!=typeof t)throw new TypeError("Start time must be set to a number.");d=t,this.hasBeenReset=!0}})),Object.defineProperty(o,"endTime",n({},l,{get:function(){return c},set:function(t){if("number"!=typeof t)throw new TypeError("End time must be set to a number.");c=t,this.hasBeenReset=!0}})),Object.defineProperty(o,"text",n({},l,{get:function(){return f},set:function(t){f=""+t,this.hasBeenReset=!0}})),Object.defineProperty(o,"region",n({},l,{get:function(){return g},set:function(t){g=t,this.hasBeenReset=!0}})),Object.defineProperty(o,"vertical",n({},l,{get:function(){return v},set:function(e){var i=function(e){return r(t,e)}(e);if(!1===i)throw new SyntaxError("An invalid or illegal string was specified.");v=i,this.hasBeenReset=!0}})),Object.defineProperty(o,"snapToLines",n({},l,{get:function(){return m},set:function(t){m=!!t,this.hasBeenReset=!0}})),Object.defineProperty(o,"line",n({},l,{get:function(){return p},set:function(t){if("number"!=typeof t&&"auto"!==t)throw new SyntaxError("An invalid number or illegal string was specified.");p=t,this.hasBeenReset=!0}})),Object.defineProperty(o,"lineAlign",n({},l,{get:function(){return y},set:function(t){var e=i(t);if(!e)throw new SyntaxError("An invalid or illegal string was specified.");y=e,this.hasBeenReset=!0}})),Object.defineProperty(o,"position",n({},l,{get:function(){return E},set:function(t){if(t<0||t>100)throw new Error("Position must be between 0 and 100.");E=t,this.hasBeenReset=!0}})),Object.defineProperty(o,"positionAlign",n({},l,{get:function(){return T},set:function(t){var e=i(t);if(!e)throw new SyntaxError("An invalid or illegal string was specified.");T=e,this.hasBeenReset=!0}})),Object.defineProperty(o,"size",n({},l,{get:function(){return S},set:function(t){if(t<0||t>100)throw new Error("Size must be between 0 and 100.");S=t,this.hasBeenReset=!0}})),Object.defineProperty(o,"align",n({},l,{get:function(){return L},set:function(t){var e=i(t);if(!e)throw new SyntaxError("An invalid or illegal string was specified.");L=e,this.hasBeenReset=!0}})),o.displayState=void 0}return a.prototype.getCueAsHTML=function(){return self.WebVTT.convertCueToDOMTree(self,this.text)},a}(),Ea=function(){function t(){}return t.prototype.decode=function(t,e){if(!t)return"";if("string"!=typeof t)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(t))},t}();function Ta(t){function e(t,e,r,i){return 3600*(0|t)+60*(0|e)+(0|r)+parseFloat(i||0)}var r=t.match(/^(?:(\d+):)?(\d{2}):(\d{2})(\.\d+)?/);return r?parseFloat(r[2])>59?e(r[2],r[3],0,r[4]):e(r[1],r[2],r[3],r[4]):null}var Sa=function(){function t(){this.values=Object.create(null)}var e=t.prototype;return e.set=function(t,e){this.get(t)||""===e||(this.values[t]=e)},e.get=function(t,e,r){return r?this.has(t)?this.values[t]:e[r]:this.has(t)?this.values[t]:e},e.has=function(t){return t in this.values},e.alt=function(t,e,r){for(var i=0;i<r.length;++i)if(e===r[i]){this.set(t,e);break}},e.integer=function(t,e){/^-?\d+$/.test(e)&&this.set(t,parseInt(e,10))},e.percent=function(t,e){if(/^([\d]{1,3})(\.[\d]*)?%$/.test(e)){var r=parseFloat(e);if(r>=0&&r<=100)return this.set(t,r),!0}return!1},t}();function La(t,e,r,i){var n=i?t.split(i):[t];for(var a in n)if("string"==typeof n[a]){var s=n[a].split(r);2===s.length&&e(s[0],s[1])}}var Aa=new ya(0,0,""),Ra="middle"===Aa.align?"middle":"center";function ka(t,e,r){var i=t;function n(){var e=Ta(t);if(null===e)throw new Error("Malformed timestamp: "+i);return t=t.replace(/^[^\sa-zA-Z-]+/,""),e}function a(){t=t.replace(/^\s+/,"")}if(a(),e.startTime=n(),a(),"--\x3e"!==t.slice(0,3))throw new Error("Malformed time stamp (time stamps must be separated by '--\x3e'): "+i);t=t.slice(3),a(),e.endTime=n(),a(),function(t,e){var i=new Sa;La(t,(function(t,e){var n;switch(t){case"region":for(var a=r.length-1;a>=0;a--)if(r[a].id===e){i.set(t,r[a].region);break}break;case"vertical":i.alt(t,e,["rl","lr"]);break;case"line":n=e.split(","),i.integer(t,n[0]),i.percent(t,n[0])&&i.set("snapToLines",!1),i.alt(t,n[0],["auto"]),2===n.length&&i.alt("lineAlign",n[1],["start",Ra,"end"]);break;case"position":n=e.split(","),i.percent(t,n[0]),2===n.length&&i.alt("positionAlign",n[1],["start",Ra,"end","line-left","line-right","auto"]);break;case"size":i.percent(t,e);break;case"align":i.alt(t,e,["start",Ra,"end","left","right"])}}),/:/,/\s/),e.region=i.get("region",null),e.vertical=i.get("vertical","");var n=i.get("line","auto");"auto"===n&&-1===Aa.line&&(n=-1),e.line=n,e.lineAlign=i.get("lineAlign","start"),e.snapToLines=i.get("snapToLines",!0),e.size=i.get("size",100),e.align=i.get("align",Ra);var a=i.get("position","auto");"auto"===a&&50===Aa.position&&(a="start"===e.align||"left"===e.align?0:"end"===e.align||"right"===e.align?100:50),e.position=a}(t,e)}function ba(t){return t.replace(/<br(?: \/)?>/gi,"\n")}var Da=function(){function t(){this.state="INITIAL",this.buffer="",this.decoder=new Ea,this.regionList=[],this.cue=null,this.oncue=void 0,this.onparsingerror=void 0,this.onflush=void 0}var e=t.prototype;return e.parse=function(t){var e=this;function r(){var t=e.buffer,r=0;for(t=ba(t);r<t.length&&"\r"!==t[r]&&"\n"!==t[r];)++r;var i=t.slice(0,r);return"\r"===t[r]&&++r,"\n"===t[r]&&++r,e.buffer=t.slice(r),i}t&&(e.buffer+=e.decoder.decode(t,{stream:!0}));try{var i="";if("INITIAL"===e.state){if(!/\r\n|\n/.test(e.buffer))return this;var n=(i=r()).match(/^()?WEBVTT([ \t].*)?$/);if(null==n||!n[0])throw new Error("Malformed WebVTT signature.");e.state="HEADER"}for(var a=!1;e.buffer;){if(!/\r\n|\n/.test(e.buffer))return this;switch(a?a=!1:i=r(),e.state){case"HEADER":/:/.test(i)?La(i,(function(t,e){}),/:/):i||(e.state="ID");continue;case"NOTE":i||(e.state="ID");continue;case"ID":if(/^NOTE($|[ \t])/.test(i)){e.state="NOTE";break}if(!i)continue;if(e.cue=new ya(0,0,""),e.state="CUE",-1===i.indexOf("--\x3e")){e.cue.id=i;continue}case"CUE":if(!e.cue){e.state="BADCUE";continue}try{ka(i,e.cue,e.regionList)}catch(t){e.cue=null,e.state="BADCUE";continue}e.state="CUETEXT";continue;case"CUETEXT":var s=-1!==i.indexOf("--\x3e");if(!i||s&&(a=!0)){e.oncue&&e.cue&&e.oncue(e.cue),e.cue=null,e.state="ID";continue}if(null===e.cue)continue;e.cue.text&&(e.cue.text+="\n"),e.cue.text+=i;continue;case"BADCUE":i||(e.state="ID")}}}catch(t){"CUETEXT"===e.state&&e.cue&&e.oncue&&e.oncue(e.cue),e.cue=null,e.state="INITIAL"===e.state?"BADWEBVTT":"BADCUE"}return this},e.flush=function(){var t=this;try{if((t.cue||"HEADER"===t.state)&&(t.buffer+="\n\n",t.parse()),"INITIAL"===t.state||"BADWEBVTT"===t.state)throw new Error("Malformed WebVTT signature.")}catch(e){t.onparsingerror&&t.onparsingerror(e)}return t.onflush&&t.onflush(),this},t}(),Ia=/\r\n|\n\r|\n|\r/g,wa=function(t,e,r){return void 0===r&&(r=0),t.slice(r,r+e.length)===e},Ca=function(t){for(var e=5381,r=t.length;r;)e=33*e^t.charCodeAt(--r);return(e>>>0).toString()};function _a(t,e,r){return Ca(t.toString())+Ca(e.toString())+Ca(r)}function xa(t,e,r,i,n,a,s){var o,l,u,h=new Da,d=Tt(new Uint8Array(t)).trim().replace(Ia,"\n").split("\n"),c=[],f=e?(o=e.baseTime,void 0===(l=e.timescale)&&(l=1),mn(o,vn,1/l)):0,g="00:00.000",v=0,m=0,p=!0;h.oncue=function(t){var a=r[i],s=r.ccOffset,o=(v-f)/9e4;if(null!=a&&a.new&&(void 0!==m?s=r.ccOffset=a.start:function(t,e,r){var i=t[e],n=t[i.prevCC];if(!n||!n.new&&i.new)return t.ccOffset=t.presentationOffset=i.start,void(i.new=!1);for(;null!=(a=n)&&a.new;){var a;t.ccOffset+=i.start-n.start,i.new=!1,n=t[(i=n).prevCC]}t.presentationOffset=r}(r,i,o)),o){if(!e)return void(u=new Error("Missing initPTS for VTT MPEGTS"));s=o-r.presentationOffset}var l=t.endTime-t.startTime,h=Sn(9e4*(t.startTime+s-m),9e4*n)/9e4;t.startTime=Math.max(h,0),t.endTime=Math.max(h+l,0);var d=t.text.trim();t.text=decodeURIComponent(encodeURIComponent(d)),t.id||(t.id=_a(t.startTime,t.endTime,d)),t.endTime>0&&c.push(t)},h.onparsingerror=function(t){u=t},h.onflush=function(){u?s(u):a(c)},d.forEach((function(t){if(p){if(wa(t,"X-TIMESTAMP-MAP=")){p=!1,t.slice(16).split(",").forEach((function(t){wa(t,"LOCAL:")?g=t.slice(6):wa(t,"MPEGTS:")&&(v=parseInt(t.slice(7)))}));try{m=function(t){var e=parseInt(t.slice(-3)),r=parseInt(t.slice(-6,-4)),i=parseInt(t.slice(-9,-7)),n=t.length>9?parseInt(t.substring(0,t.indexOf(":"))):0;if(!(y(e)&&y(r)&&y(i)&&y(n)))throw Error("Malformed X-TIMESTAMP-MAP: Local:"+t);return e+=1e3*r,(e+=6e4*i)+36e5*n}(g)/1e3}catch(t){u=t}return}""===t&&(p=!1)}h.parse(t+"\n")})),h.flush()}var Pa="stpp.ttml.im1t",Fa=/^(\d{2,}):(\d{2}):(\d{2}):(\d{2})\.?(\d+)?$/,Ma=/^(\d*(?:\.\d*)?)(h|m|s|ms|f|t)$/,Oa={left:"start",center:"center",right:"end",start:"start",end:"end"};function Na(t,e,r,i){var n=_t(new Uint8Array(t),["mdat"]);if(0!==n.length){var a,s,l,u,h=n.map((function(t){return Tt(t)})),d=(a=e.baseTime,s=1,void 0===(l=e.timescale)&&(l=1),void 0===u&&(u=!1),mn(a,s,1/l,u));try{h.forEach((function(t){return r(function(t,e){var r=(new DOMParser).parseFromString(t,"text/xml"),i=r.getElementsByTagName("tt")[0];if(!i)throw new Error("Invalid ttml");var n={frameRate:30,subFrameRate:1,frameRateMultiplier:0,tickRate:0},a=Object.keys(n).reduce((function(t,e){return t[e]=i.getAttribute("ttp:"+e)||n[e],t}),{}),s="preserve"!==i.getAttribute("xml:space"),l=Ba(Ua(i,"styling","style")),u=Ba(Ua(i,"layout","region")),h=Ua(i,"body","[begin]");return[].map.call(h,(function(t){var r=Ga(t,s);if(!r||!t.hasAttribute("begin"))return null;var i=Va(t.getAttribute("begin"),a),n=Va(t.getAttribute("dur"),a),h=Va(t.getAttribute("end"),a);if(null===i)throw Ha(t);if(null===h){if(null===n)throw Ha(t);h=i+n}var d=new ya(i-e,h-e,r);d.id=_a(d.startTime,d.endTime,d.text);var c=function(t,e,r){var i="http://www.w3.org/ns/ttml#styling",n=null,a=["displayAlign","textAlign","color","backgroundColor","fontSize","fontFamily"],s=null!=t&&t.hasAttribute("style")?t.getAttribute("style"):null;return s&&r.hasOwnProperty(s)&&(n=r[s]),a.reduce((function(r,a){var s=Ka(e,i,a)||Ka(t,i,a)||Ka(n,i,a);return s&&(r[a]=s),r}),{})}(u[t.getAttribute("region")],l[t.getAttribute("style")],l),f=c.textAlign;if(f){var g=Oa[f];g&&(d.lineAlign=g),d.align=f}return o(d,c),d})).filter((function(t){return null!==t}))}(t,d))}))}catch(t){i(t)}}else i(new Error("Could not parse IMSC1 mdat"))}function Ua(t,e,r){var i=t.getElementsByTagName(e)[0];return i?[].slice.call(i.querySelectorAll(r)):[]}function Ba(t){return t.reduce((function(t,e){var r=e.getAttribute("xml:id");return r&&(t[r]=e),t}),{})}function Ga(t,e){return[].slice.call(t.childNodes).reduce((function(t,r,i){var n;return"br"===r.nodeName&&i?t+"\n":null!=(n=r.childNodes)&&n.length?Ga(r,e):e?t+r.textContent.trim().replace(/\s+/g," "):t+r.textContent}),"")}function Ka(t,e,r){return t&&t.hasAttributeNS(e,r)?t.getAttributeNS(e,r):null}function Ha(t){return new Error("Could not parse ttml timestamp "+t)}function Va(t,e){if(!t)return null;var r=Ta(t);return null===r&&(Fa.test(t)?r=function(t,e){var r=Fa.exec(t),i=(0|r[4])+(0|r[5])/e.subFrameRate;return 3600*(0|r[1])+60*(0|r[2])+(0|r[3])+i/e.frameRate}(t,e):Ma.test(t)&&(r=function(t,e){var r=Ma.exec(t),i=Number(r[1]);switch(r[2]){case"h":return 3600*i;case"m":return 60*i;case"ms":return 1e3*i;case"f":return i/e.frameRate;case"t":return i/e.tickRate}return i}(t,e))),r}var Ya=function(){function t(t){this.hls=void 0,this.media=null,this.config=void 0,this.enabled=!0,this.Cues=void 0,this.textTracks=[],this.tracks=[],this.initPTS=[],this.unparsedVttFrags=[],this.captionsTracks={},this.nonNativeCaptionsTracks={},this.cea608Parser1=void 0,this.cea608Parser2=void 0,this.lastCc=-1,this.lastSn=-1,this.lastPartIndex=-1,this.prevCC=-1,this.vttCCs={ccOffset:0,presentationOffset:0,0:{start:0,prevCC:-1,new:!0}},this.captionsProperties=void 0,this.hls=t,this.config=t.config,this.Cues=t.config.cueHandler,this.captionsProperties={textTrack1:{label:this.config.captionsTextTrack1Label,languageCode:this.config.captionsTextTrack1LanguageCode},textTrack2:{label:this.config.captionsTextTrack2Label,languageCode:this.config.captionsTextTrack2LanguageCode},textTrack3:{label:this.config.captionsTextTrack3Label,languageCode:this.config.captionsTextTrack3LanguageCode},textTrack4:{label:this.config.captionsTextTrack4Label,languageCode:this.config.captionsTextTrack4LanguageCode}},t.on(S.MEDIA_ATTACHING,this.onMediaAttaching,this),t.on(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.MANIFEST_LOADED,this.onManifestLoaded,this),t.on(S.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),t.on(S.FRAG_LOADING,this.onFragLoading,this),t.on(S.FRAG_LOADED,this.onFragLoaded,this),t.on(S.FRAG_PARSING_USERDATA,this.onFragParsingUserdata,this),t.on(S.FRAG_DECRYPTED,this.onFragDecrypted,this),t.on(S.INIT_PTS_FOUND,this.onInitPtsFound,this),t.on(S.SUBTITLE_TRACKS_CLEARED,this.onSubtitleTracksCleared,this),t.on(S.BUFFER_FLUSHING,this.onBufferFlushing,this)}var e=t.prototype;return e.destroy=function(){var t=this.hls;t.off(S.MEDIA_ATTACHING,this.onMediaAttaching,this),t.off(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.MANIFEST_LOADED,this.onManifestLoaded,this),t.off(S.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),t.off(S.FRAG_LOADING,this.onFragLoading,this),t.off(S.FRAG_LOADED,this.onFragLoaded,this),t.off(S.FRAG_PARSING_USERDATA,this.onFragParsingUserdata,this),t.off(S.FRAG_DECRYPTED,this.onFragDecrypted,this),t.off(S.INIT_PTS_FOUND,this.onInitPtsFound,this),t.off(S.SUBTITLE_TRACKS_CLEARED,this.onSubtitleTracksCleared,this),t.off(S.BUFFER_FLUSHING,this.onBufferFlushing,this),this.hls=this.config=null,this.cea608Parser1=this.cea608Parser2=void 0},e.initCea608Parsers=function(){if(this.config.enableCEA708Captions&&(!this.cea608Parser1||!this.cea608Parser2)){var t=new pa(this,"textTrack1"),e=new pa(this,"textTrack2"),r=new pa(this,"textTrack3"),i=new pa(this,"textTrack4");this.cea608Parser1=new ga(1,t,e),this.cea608Parser2=new ga(3,r,i)}},e.addCues=function(t,e,r,i,n){for(var a,s,o,l,u=!1,h=n.length;h--;){var d=n[h],c=(a=d[0],s=d[1],o=e,l=r,Math.min(s,l)-Math.max(a,o));if(c>=0&&(d[0]=Math.min(d[0],e),d[1]=Math.max(d[1],r),u=!0,c/(r-e)>.5))return}if(u||n.push([e,r]),this.config.renderTextTracksNatively){var f=this.captionsTracks[t];this.Cues.newCue(f,e,r,i)}else{var g=this.Cues.newCue(null,e,r,i);this.hls.trigger(S.CUES_PARSED,{type:"captions",cues:g,track:t})}},e.onInitPtsFound=function(t,e){var r=this,i=e.frag,n=e.id,a=e.initPTS,s=e.timescale,o=this.unparsedVttFrags;"main"===n&&(this.initPTS[i.cc]={baseTime:a,timescale:s}),o.length&&(this.unparsedVttFrags=[],o.forEach((function(t){r.onFragLoaded(S.FRAG_LOADED,t)})))},e.getExistingTrack=function(t,e){var r=this.media;if(r)for(var i=0;i<r.textTracks.length;i++){var n=r.textTracks[i];if(ja(n,{name:t,lang:e,attrs:{}}))return n}return null},e.createCaptionsTrack=function(t){this.config.renderTextTracksNatively?this.createNativeTrack(t):this.createNonNativeTrack(t)},e.createNativeTrack=function(t){if(!this.captionsTracks[t]){var e=this.captionsProperties,r=this.captionsTracks,i=this.media,n=e[t],a=n.label,s=n.languageCode,o=this.getExistingTrack(a,s);if(o)r[t]=o,Oe(r[t]),Fe(r[t],i);else{var l=this.createTextTrack("captions",a,s);l&&(l[t]=!0,r[t]=l)}}},e.createNonNativeTrack=function(t){if(!this.nonNativeCaptionsTracks[t]){var e=this.captionsProperties[t];if(e){var r={_id:t,label:e.label,kind:"captions",default:!!e.media&&!!e.media.default,closedCaptions:e.media};this.nonNativeCaptionsTracks[t]=r,this.hls.trigger(S.NON_NATIVE_TEXT_TRACKS_FOUND,{tracks:[r]})}}},e.createTextTrack=function(t,e,r){var i=this.media;if(i)return i.addTextTrack(t,e,r)},e.onMediaAttaching=function(t,e){this.media=e.media,this._cleanTracks()},e.onMediaDetaching=function(){var t=this.captionsTracks;Object.keys(t).forEach((function(e){Oe(t[e]),delete t[e]})),this.nonNativeCaptionsTracks={}},e.onManifestLoading=function(){this.lastCc=-1,this.lastSn=-1,this.lastPartIndex=-1,this.prevCC=-1,this.vttCCs={ccOffset:0,presentationOffset:0,0:{start:0,prevCC:-1,new:!0}},this._cleanTracks(),this.tracks=[],this.captionsTracks={},this.nonNativeCaptionsTracks={},this.textTracks=[],this.unparsedVttFrags=[],this.initPTS=[],this.cea608Parser1&&this.cea608Parser2&&(this.cea608Parser1.reset(),this.cea608Parser2.reset())},e._cleanTracks=function(){var t=this.media;if(t){var e=t.textTracks;if(e)for(var r=0;r<e.length;r++)Oe(e[r])}},e.onSubtitleTracksUpdated=function(t,e){var r=this,i=e.subtitleTracks||[],n=i.some((function(t){return t.textCodec===Pa}));if(this.config.enableWebVTT||n&&this.config.enableIMSC1){if(Gn(this.tracks,i))return void(this.tracks=i);if(this.textTracks=[],this.tracks=i,this.config.renderTextTracksNatively){var a=this.media,s=a?Ue(a.textTracks):null;if(this.tracks.forEach((function(t,e){var i;if(s){for(var n=null,a=0;a<s.length;a++)if(s[a]&&ja(s[a],t)){n=s[a],s[a]=null;break}n&&(i=n)}if(i)Oe(i);else{var o=Wa(t);(i=r.createTextTrack(o,t.name,t.lang))&&(i.mode="disabled")}i&&r.textTracks.push(i)})),null!=s&&s.length){var o=s.filter((function(t){return null!==t})).map((function(t){return t.label}));o.length&&w.warn("Media element contains unused subtitle tracks: "+o.join(", ")+". Replace media element for each source to clear TextTracks and captions menu.")}}else if(this.tracks.length){var l=this.tracks.map((function(t){return{label:t.name,kind:t.type.toLowerCase(),default:t.default,subtitleTrack:t}}));this.hls.trigger(S.NON_NATIVE_TEXT_TRACKS_FOUND,{tracks:l})}}},e.onManifestLoaded=function(t,e){var r=this;this.config.enableCEA708Captions&&e.captions&&e.captions.forEach((function(t){var e=/(?:CC|SERVICE)([1-4])/.exec(t.instreamId);if(e){var i="textTrack"+e[1],n=r.captionsProperties[i];n&&(n.label=t.name,t.lang&&(n.languageCode=t.lang),n.media=t)}}))},e.closedCaptionsForLevel=function(t){var e=this.hls.levels[t.level];return null==e?void 0:e.attrs["CLOSED-CAPTIONS"]},e.onFragLoading=function(t,e){this.initCea608Parsers();var r=this.cea608Parser1,i=this.cea608Parser2,n=this.lastCc,a=this.lastSn,s=this.lastPartIndex;if(this.enabled&&r&&i&&e.frag.type===Ie){var o,l,u=e.frag,h=u.cc,d=u.sn,c=null!=(o=null==e||null==(l=e.part)?void 0:l.index)?o:-1;d===a+1||d===a&&c===s+1||h===n||(r.reset(),i.reset()),this.lastCc=h,this.lastSn=d,this.lastPartIndex=c}},e.onFragLoaded=function(t,e){var r=e.frag,i=e.payload;if(r.type===Ce)if(i.byteLength){var n=r.decryptdata,a="stats"in e;if(null==n||!n.encrypted||a){var s=this.tracks[r.level],o=this.vttCCs;o[r.cc]||(o[r.cc]={start:r.start,prevCC:this.prevCC,new:!0},this.prevCC=r.cc),s&&s.textCodec===Pa?this._parseIMSC1(r,i):this._parseVTTs(e)}}else this.hls.trigger(S.SUBTITLE_FRAG_PROCESSED,{success:!1,frag:r,error:new Error("Empty subtitle payload")})},e._parseIMSC1=function(t,e){var r=this,i=this.hls;Na(e,this.initPTS[t.cc],(function(e){r._appendCues(e,t.level),i.trigger(S.SUBTITLE_FRAG_PROCESSED,{success:!0,frag:t})}),(function(e){w.log("Failed to parse IMSC1: "+e),i.trigger(S.SUBTITLE_FRAG_PROCESSED,{success:!1,frag:t,error:e})}))},e._parseVTTs=function(t){var e,r=this,i=t.frag,n=t.payload,a=this.initPTS,s=this.unparsedVttFrags,o=a.length-1;if(a[i.cc]||-1!==o){var l=this.hls;xa(null!=(e=i.initSegment)&&e.data?Gt(i.initSegment.data,new Uint8Array(n)):n,this.initPTS[i.cc],this.vttCCs,i.cc,i.start,(function(t){r._appendCues(t,i.level),l.trigger(S.SUBTITLE_FRAG_PROCESSED,{success:!0,frag:i})}),(function(e){var a="Missing initPTS for VTT MPEGTS"===e.message;a?s.push(t):r._fallbackToIMSC1(i,n),w.log("Failed to parse VTT cue: "+e),a&&o>i.cc||l.trigger(S.SUBTITLE_FRAG_PROCESSED,{success:!1,frag:i,error:e})}))}else s.push(t)},e._fallbackToIMSC1=function(t,e){var r=this,i=this.tracks[t.level];i.textCodec||Na(e,this.initPTS[t.cc],(function(){i.textCodec=Pa,r._parseIMSC1(t,e)}),(function(){i.textCodec="wvtt"}))},e._appendCues=function(t,e){var r=this.hls;if(this.config.renderTextTracksNatively){var i=this.textTracks[e];if(!i||"disabled"===i.mode)return;t.forEach((function(t){return Me(i,t)}))}else{var n=this.tracks[e];if(!n)return;var a=n.default?"default":"subtitles"+e;r.trigger(S.CUES_PARSED,{type:"subtitles",cues:t,track:a})}},e.onFragDecrypted=function(t,e){e.frag.type===Ce&&this.onFragLoaded(S.FRAG_LOADED,e)},e.onSubtitleTracksCleared=function(){this.tracks=[],this.captionsTracks={}},e.onFragParsingUserdata=function(t,e){this.initCea608Parsers();var r=this.cea608Parser1,i=this.cea608Parser2;if(this.enabled&&r&&i){var n=e.frag,a=e.samples;if(n.type!==Ie||"NONE"!==this.closedCaptionsForLevel(n))for(var s=0;s<a.length;s++){var o=a[s].bytes;if(o){var l=this.extractCea608Data(o);r.addData(a[s].pts,l[0]),i.addData(a[s].pts,l[1])}}}},e.onBufferFlushing=function(t,e){var r=e.startOffset,i=e.endOffset,n=e.endOffsetSubtitles,a=e.type,s=this.media;if(s&&!(s.currentTime<i)){if(!a||"video"===a){var o=this.captionsTracks;Object.keys(o).forEach((function(t){return Ne(o[t],r,i)}))}if(this.config.renderTextTracksNatively&&0===r&&void 0!==n){var l=this.textTracks;Object.keys(l).forEach((function(t){return Ne(l[t],r,n)}))}}},e.extractCea608Data=function(t){for(var e=[[],[]],r=31&t[0],i=2,n=0;n<r;n++){var a=t[i++],s=127&t[i++],o=127&t[i++];if((0!==s||0!==o)&&0!=(4&a)){var l=3&a;0!==l&&1!==l||(e[l].push(s),e[l].push(o))}}return e},t}();function Wa(t){return t.characteristics&&/transcribes-spoken-dialog/gi.test(t.characteristics)&&/describes-music-and-sound/gi.test(t.characteristics)?"captions":"subtitles"}function ja(t,e){return!!t&&t.kind===Wa(e)&&Hn(e,t)}var qa=function(){function t(t){this.hls=void 0,this.autoLevelCapping=void 0,this.firstLevel=void 0,this.media=void 0,this.restrictedLevels=void 0,this.timer=void 0,this.clientRect=void 0,this.streamController=void 0,this.hls=t,this.autoLevelCapping=Number.POSITIVE_INFINITY,this.firstLevel=-1,this.media=null,this.restrictedLevels=[],this.timer=void 0,this.clientRect=null,this.registerListeners()}var e=t.prototype;return e.setStreamController=function(t){this.streamController=t},e.destroy=function(){this.hls&&this.unregisterListener(),this.timer&&this.stopCapping(),this.media=null,this.clientRect=null,this.hls=this.streamController=null},e.registerListeners=function(){var t=this.hls;t.on(S.FPS_DROP_LEVEL_CAPPING,this.onFpsDropLevelCapping,this),t.on(S.MEDIA_ATTACHING,this.onMediaAttaching,this),t.on(S.MANIFEST_PARSED,this.onManifestParsed,this),t.on(S.LEVELS_UPDATED,this.onLevelsUpdated,this),t.on(S.BUFFER_CODECS,this.onBufferCodecs,this),t.on(S.MEDIA_DETACHING,this.onMediaDetaching,this)},e.unregisterListener=function(){var t=this.hls;t.off(S.FPS_DROP_LEVEL_CAPPING,this.onFpsDropLevelCapping,this),t.off(S.MEDIA_ATTACHING,this.onMediaAttaching,this),t.off(S.MANIFEST_PARSED,this.onManifestParsed,this),t.off(S.LEVELS_UPDATED,this.onLevelsUpdated,this),t.off(S.BUFFER_CODECS,this.onBufferCodecs,this),t.off(S.MEDIA_DETACHING,this.onMediaDetaching,this)},e.onFpsDropLevelCapping=function(t,e){var r=this.hls.levels[e.droppedLevel];this.isLevelAllowed(r)&&this.restrictedLevels.push({bitrate:r.bitrate,height:r.height,width:r.width})},e.onMediaAttaching=function(t,e){this.media=e.media instanceof HTMLVideoElement?e.media:null,this.clientRect=null,this.timer&&this.hls.levels.length&&this.detectPlayerSize()},e.onManifestParsed=function(t,e){var r=this.hls;this.restrictedLevels=[],this.firstLevel=e.firstLevel,r.config.capLevelToPlayerSize&&e.video&&this.startCapping()},e.onLevelsUpdated=function(t,e){this.timer&&y(this.autoLevelCapping)&&this.detectPlayerSize()},e.onBufferCodecs=function(t,e){this.hls.config.capLevelToPlayerSize&&e.video&&this.startCapping()},e.onMediaDetaching=function(){this.stopCapping()},e.detectPlayerSize=function(){if(this.media){if(this.mediaHeight<=0||this.mediaWidth<=0)return void(this.clientRect=null);var t=this.hls.levels;if(t.length){var e=this.hls,r=this.getMaxLevel(t.length-1);r!==this.autoLevelCapping&&w.log("Setting autoLevelCapping to "+r+": "+t[r].height+"p@"+t[r].bitrate+" for media "+this.mediaWidth+"x"+this.mediaHeight),e.autoLevelCapping=r,e.autoLevelCapping>this.autoLevelCapping&&this.streamController&&this.streamController.nextLevelSwitch(),this.autoLevelCapping=e.autoLevelCapping}}},e.getMaxLevel=function(e){var r=this,i=this.hls.levels;if(!i.length)return-1;var n=i.filter((function(t,i){return r.isLevelAllowed(t)&&i<=e}));return this.clientRect=null,t.getMaxLevelByMediaSize(n,this.mediaWidth,this.mediaHeight)},e.startCapping=function(){this.timer||(this.autoLevelCapping=Number.POSITIVE_INFINITY,self.clearInterval(this.timer),this.timer=self.setInterval(this.detectPlayerSize.bind(this),1e3),this.detectPlayerSize())},e.stopCapping=function(){this.restrictedLevels=[],this.firstLevel=-1,this.autoLevelCapping=Number.POSITIVE_INFINITY,this.timer&&(self.clearInterval(this.timer),this.timer=void 0)},e.getDimensions=function(){if(this.clientRect)return this.clientRect;var t=this.media,e={width:0,height:0};if(t){var r=t.getBoundingClientRect();e.width=r.width,e.height=r.height,e.width||e.height||(e.width=r.right-r.left||t.width||0,e.height=r.bottom-r.top||t.height||0)}return this.clientRect=e,e},e.isLevelAllowed=function(t){return!this.restrictedLevels.some((function(e){return t.bitrate===e.bitrate&&t.width===e.width&&t.height===e.height}))},t.getMaxLevelByMediaSize=function(t,e,r){if(null==t||!t.length)return-1;for(var i,n,a=t.length-1,s=Math.max(e,r),o=0;o<t.length;o+=1){var l=t[o];if((l.width>=s||l.height>=s)&&(i=l,!(n=t[o+1])||i.width!==n.width||i.height!==n.height)){a=o;break}}return a},s(t,[{key:"mediaWidth",get:function(){return this.getDimensions().width*this.contentScaleFactor}},{key:"mediaHeight",get:function(){return this.getDimensions().height*this.contentScaleFactor}},{key:"contentScaleFactor",get:function(){var t=1;if(!this.hls.config.ignoreDevicePixelRatio)try{t=self.devicePixelRatio}catch(t){}return t}}]),t}(),Xa=function(){function t(t){this.hls=void 0,this.isVideoPlaybackQualityAvailable=!1,this.timer=void 0,this.media=null,this.lastTime=void 0,this.lastDroppedFrames=0,this.lastDecodedFrames=0,this.streamController=void 0,this.hls=t,this.registerListeners()}var e=t.prototype;return e.setStreamController=function(t){this.streamController=t},e.registerListeners=function(){this.hls.on(S.MEDIA_ATTACHING,this.onMediaAttaching,this)},e.unregisterListeners=function(){this.hls.off(S.MEDIA_ATTACHING,this.onMediaAttaching,this)},e.destroy=function(){this.timer&&clearInterval(this.timer),this.unregisterListeners(),this.isVideoPlaybackQualityAvailable=!1,this.media=null},e.onMediaAttaching=function(t,e){var r=this.hls.config;if(r.capLevelOnFPSDrop){var i=e.media instanceof self.HTMLVideoElement?e.media:null;this.media=i,i&&"function"==typeof i.getVideoPlaybackQuality&&(this.isVideoPlaybackQualityAvailable=!0),self.clearInterval(this.timer),this.timer=self.setInterval(this.checkFPSInterval.bind(this),r.fpsDroppedMonitoringPeriod)}},e.checkFPS=function(t,e,r){var i=performance.now();if(e){if(this.lastTime){var n=i-this.lastTime,a=r-this.lastDroppedFrames,s=e-this.lastDecodedFrames,o=1e3*a/n,l=this.hls;if(l.trigger(S.FPS_DROP,{currentDropped:a,currentDecoded:s,totalDroppedFrames:r}),o>0&&a>l.config.fpsDroppedMonitoringThreshold*s){var u=l.currentLevel;w.warn("drop FPS ratio greater than max allowed value for currentLevel: "+u),u>0&&(-1===l.autoLevelCapping||l.autoLevelCapping>=u)&&(u-=1,l.trigger(S.FPS_DROP_LEVEL_CAPPING,{level:u,droppedLevel:l.currentLevel}),l.autoLevelCapping=u,this.streamController.nextLevelSwitch())}}this.lastTime=i,this.lastDroppedFrames=r,this.lastDecodedFrames=e}},e.checkFPSInterval=function(){var t=this.media;if(t)if(this.isVideoPlaybackQualityAvailable){var e=t.getVideoPlaybackQuality();this.checkFPS(t,e.totalVideoFrames,e.droppedVideoFrames)}else this.checkFPS(t,t.webkitDecodedFrameCount,t.webkitDroppedFrameCount)},t}(),za="[eme]",Qa=function(){function t(e){this.hls=void 0,this.config=void 0,this.media=null,this.keyFormatPromise=null,this.keySystemAccessPromises={},this._requestLicenseFailureCount=0,this.mediaKeySessions=[],this.keyIdToKeySessionPromise={},this.setMediaKeysQueue=t.CDMCleanupPromise?[t.CDMCleanupPromise]:[],this.onMediaEncrypted=this._onMediaEncrypted.bind(this),this.onWaitingForKey=this._onWaitingForKey.bind(this),this.debug=w.debug.bind(w,za),this.log=w.log.bind(w,za),this.warn=w.warn.bind(w,za),this.error=w.error.bind(w,za),this.hls=e,this.config=e.config,this.registerListeners()}var e=t.prototype;return e.destroy=function(){this.unregisterListeners(),this.onMediaDetached();var t=this.config;t.requestMediaKeySystemAccessFunc=null,t.licenseXhrSetup=t.licenseResponseCallback=void 0,t.drmSystems=t.drmSystemOptions={},this.hls=this.onMediaEncrypted=this.onWaitingForKey=this.keyIdToKeySessionPromise=null,this.config=null},e.registerListeners=function(){this.hls.on(S.MEDIA_ATTACHED,this.onMediaAttached,this),this.hls.on(S.MEDIA_DETACHED,this.onMediaDetached,this),this.hls.on(S.MANIFEST_LOADING,this.onManifestLoading,this),this.hls.on(S.MANIFEST_LOADED,this.onManifestLoaded,this)},e.unregisterListeners=function(){this.hls.off(S.MEDIA_ATTACHED,this.onMediaAttached,this),this.hls.off(S.MEDIA_DETACHED,this.onMediaDetached,this),this.hls.off(S.MANIFEST_LOADING,this.onManifestLoading,this),this.hls.off(S.MANIFEST_LOADED,this.onManifestLoaded,this)},e.getLicenseServerUrl=function(t){var e=this.config,r=e.drmSystems,i=e.widevineLicenseUrl,n=r[t];if(n)return n.licenseUrl;if(t===q.WIDEVINE&&i)return i;throw new Error('no license server URL configured for key-system "'+t+'"')},e.getServerCertificateUrl=function(t){var e=this.config.drmSystems[t];if(e)return e.serverCertificateUrl;this.log('No Server Certificate in config.drmSystems["'+t+'"]')},e.attemptKeySystemAccess=function(t){var e=this,r=this.hls.levels,i=function(t,e,r){return!!t&&r.indexOf(t)===e},n=r.map((function(t){return t.audioCodec})).filter(i),a=r.map((function(t){return t.videoCodec})).filter(i);return n.length+a.length===0&&a.push("avc1.42e01e"),new Promise((function(r,i){!function t(s){var o=s.shift();e.getMediaKeysPromise(o,n,a).then((function(t){return r({keySystem:o,mediaKeys:t})})).catch((function(e){s.length?t(s):i(e instanceof es?e:new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_NO_ACCESS,error:e,fatal:!0},e.message))}))}(t)}))},e.requestMediaKeySystemAccess=function(t,e){var r=this.config.requestMediaKeySystemAccessFunc;if("function"!=typeof r){var i="Configured requestMediaKeySystemAccess is not a function "+r;return null===it&&"http:"===self.location.protocol&&(i="navigator.requestMediaKeySystemAccess is not available over insecure protocol "+location.protocol),Promise.reject(new Error(i))}return r(t,e)},e.getMediaKeysPromise=function(t,e,r){var i=this,n=function(t,e,r,i){var n;switch(t){case q.FAIRPLAY:n=["cenc","sinf"];break;case q.WIDEVINE:case q.PLAYREADY:n=["cenc"];break;case q.CLEARKEY:n=["cenc","keyids"];break;default:throw new Error("Unknown key-system: "+t)}return function(t,e,r,i){return[{initDataTypes:t,persistentState:i.persistentState||"optional",distinctiveIdentifier:i.distinctiveIdentifier||"optional",sessionTypes:i.sessionTypes||[i.sessionType||"temporary"],audioCapabilities:e.map((function(t){return{contentType:'audio/mp4; codecs="'+t+'"',robustness:i.audioRobustness||"",encryptionScheme:i.audioEncryptionScheme||null}})),videoCapabilities:r.map((function(t){return{contentType:'video/mp4; codecs="'+t+'"',robustness:i.videoRobustness||"",encryptionScheme:i.videoEncryptionScheme||null}}))}]}(n,e,r,i)}(t,e,r,this.config.drmSystemOptions),a=this.keySystemAccessPromises[t],s=null==a?void 0:a.keySystemAccess;if(!s){this.log('Requesting encrypted media "'+t+'" key-system access with config: '+JSON.stringify(n)),s=this.requestMediaKeySystemAccess(t,n);var o=this.keySystemAccessPromises[t]={keySystemAccess:s};return s.catch((function(e){i.log('Failed to obtain access to key-system "'+t+'": '+e)})),s.then((function(e){i.log('Access for key-system "'+e.keySystem+'" obtained');var r=i.fetchServerCertificate(t);return i.log('Create media-keys for "'+t+'"'),o.mediaKeys=e.createMediaKeys().then((function(e){return i.log('Media-keys created for "'+t+'"'),r.then((function(r){return r?i.setMediaKeysServerCertificate(e,t,r):e}))})),o.mediaKeys.catch((function(e){i.error('Failed to create media-keys for "'+t+'"}: '+e)})),o.mediaKeys}))}return s.then((function(){return a.mediaKeys}))},e.createMediaKeySessionContext=function(t){var e=t.decryptdata,r=t.keySystem,i=t.mediaKeys;this.log('Creating key-system session "'+r+'" keyId: '+Lt(e.keyId||[]));var n=i.createSession(),a={decryptdata:e,keySystem:r,mediaKeys:i,mediaKeysSession:n,keyStatus:"status-pending"};return this.mediaKeySessions.push(a),a},e.renewKeySession=function(t){var e=t.decryptdata;if(e.pssh){var r=this.createMediaKeySessionContext(t),i=this.getKeyIdString(e);this.keyIdToKeySessionPromise[i]=this.generateRequestWithPreferredKeySession(r,"cenc",e.pssh,"expired")}else this.warn("Could not renew expired session. Missing pssh initData.");this.removeSession(t)},e.getKeyIdString=function(t){if(!t)throw new Error("Could not read keyId of undefined decryptdata");if(null===t.keyId)throw new Error("keyId is null");return Lt(t.keyId)},e.updateKeySession=function(t,e){var r,i=t.mediaKeysSession;return this.log('Updating key-session "'+i.sessionId+'" for keyID '+Lt((null==(r=t.decryptdata)?void 0:r.keyId)||[])+"\n } (data length: "+(e?e.byteLength:e)+")"),i.update(e)},e.selectKeySystemFormat=function(t){var e=Object.keys(t.levelkeys||{});return this.keyFormatPromise||(this.log("Selecting key-system from fragment (sn: "+t.sn+" "+t.type+": "+t.level+") key formats "+e.join(", ")),this.keyFormatPromise=this.getKeyFormatPromise(e)),this.keyFormatPromise},e.getKeyFormatPromise=function(t){var e=this;return new Promise((function(r,i){var n=et(e.config),a=t.map($).filter((function(t){return!!t&&-1!==n.indexOf(t)}));return e.getKeySystemSelectionPromise(a).then((function(t){var e=t.keySystem,n=tt(e);n?r(n):i(new Error('Unable to find format for key-system "'+e+'"'))})).catch(i)}))},e.loadKey=function(t){var e=this,r=t.keyInfo.decryptdata,i=this.getKeyIdString(r),n="(keyId: "+i+' format: "'+r.keyFormat+'" method: '+r.method+" uri: "+r.uri+")";this.log("Starting session for key "+n);var a=this.keyIdToKeySessionPromise[i];return a||(a=this.keyIdToKeySessionPromise[i]=this.getKeySystemForKeyPromise(r).then((function(i){var a=i.keySystem,s=i.mediaKeys;return e.throwIfDestroyed(),e.log("Handle encrypted media sn: "+t.frag.sn+" "+t.frag.type+": "+t.frag.level+" using key "+n),e.attemptSetMediaKeys(a,s).then((function(){e.throwIfDestroyed();var t=e.createMediaKeySessionContext({keySystem:a,mediaKeys:s,decryptdata:r});return e.generateRequestWithPreferredKeySession(t,"cenc",r.pssh,"playlist-key")}))}))).catch((function(t){return e.handleError(t)})),a},e.throwIfDestroyed=function(t){if(!this.hls)throw new Error("invalid state")},e.handleError=function(t){this.hls&&(this.error(t.message),t instanceof es?this.hls.trigger(S.ERROR,t.data):this.hls.trigger(S.ERROR,{type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_NO_KEYS,error:t,fatal:!0}))},e.getKeySystemForKeyPromise=function(t){var e=this.getKeyIdString(t),r=this.keyIdToKeySessionPromise[e];if(!r){var i=$(t.keyFormat),n=i?[i]:et(this.config);return this.attemptKeySystemAccess(n)}return r},e.getKeySystemSelectionPromise=function(t){if(t.length||(t=et(this.config)),0===t.length)throw new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_NO_CONFIGURED_LICENSE,fatal:!0},"Missing key-system license configuration options "+JSON.stringify({drmSystems:this.config.drmSystems}));return this.attemptKeySystemAccess(t)},e._onMediaEncrypted=function(t){var e=this,r=t.initDataType,i=t.initData;if(this.debug('"'+t.type+'" event: init data type: "'+r+'"'),null!==i){var n,a;if("sinf"===r&&this.config.drmSystems[q.FAIRPLAY]){var s=bt(new Uint8Array(i));try{var o=V(JSON.parse(s).sinf),l=Ut(new Uint8Array(o));if(!l)return;n=l.subarray(8,24),a=q.FAIRPLAY}catch(t){return void this.warn('Failed to parse sinf "encrypted" event message initData')}}else{var u=function(t){if(!(t instanceof ArrayBuffer)||t.byteLength<32)return null;var e={version:0,systemId:"",kids:null,data:null},r=new DataView(t),i=r.getUint32(0);if(t.byteLength!==i&&i>44)return null;if(1886614376!==r.getUint32(4))return null;if(e.version=r.getUint32(8)>>>24,e.version>1)return null;e.systemId=Lt(new Uint8Array(t,12,16));var n=r.getUint32(28);if(0===e.version){if(i-32<n)return null;e.data=new Uint8Array(t,32,n)}else if(1===e.version){e.kids=[];for(var a=0;a<n;a++)e.kids.push(new Uint8Array(t,32+16*a,16))}return e}(i);if(null===u)return;0===u.version&&u.systemId===Z&&u.data&&(n=u.data.subarray(8,24)),a=function(t){if(t===Z)return q.WIDEVINE}(u.systemId)}if(a&&n){for(var h,d=Lt(n),c=this.keyIdToKeySessionPromise,f=this.mediaKeySessions,g=c[d],v=function(){var t=f[m],a=t.decryptdata;if(a.pssh||!a.keyId)return 0;var s=Lt(a.keyId);return d===s||-1!==a.uri.replace(/-/g,"").indexOf(d)?(g=c[s],delete c[s],a.pssh=new Uint8Array(i),a.keyId=n,g=c[d]=g.then((function(){return e.generateRequestWithPreferredKeySession(t,r,i,"encrypted-event-key-match")})),1):void 0},m=0;m<f.length&&(0===(h=v())||1!==h);m++);g||(g=c[d]=this.getKeySystemSelectionPromise([a]).then((function(t){var a,s=t.keySystem,o=t.mediaKeys;e.throwIfDestroyed();var l=new qt("ISO-23001-7",d,null!=(a=tt(s))?a:"");return l.pssh=new Uint8Array(i),l.keyId=n,e.attemptSetMediaKeys(s,o).then((function(){e.throwIfDestroyed();var t=e.createMediaKeySessionContext({decryptdata:l,keySystem:s,mediaKeys:o});return e.generateRequestWithPreferredKeySession(t,r,i,"encrypted-event-no-match")}))}))),g.catch((function(t){return e.handleError(t)}))}}},e._onWaitingForKey=function(t){this.log('"'+t.type+'" event')},e.attemptSetMediaKeys=function(t,e){var r=this,i=this.setMediaKeysQueue.slice();this.log('Setting media-keys for "'+t+'"');var n=Promise.all(i).then((function(){if(!r.media)throw new Error("Attempted to set mediaKeys without media element attached");return r.media.setMediaKeys(e)}));return this.setMediaKeysQueue.push(n),n.then((function(){r.log('Media-keys set for "'+t+'"'),i.push(n),r.setMediaKeysQueue=r.setMediaKeysQueue.filter((function(t){return-1===i.indexOf(t)}))}))},e.generateRequestWithPreferredKeySession=function(t,e,r,i){var n,a,s=this,o=null==(n=this.config.drmSystems)||null==(a=n[t.keySystem])?void 0:a.generateRequest;if(o)try{var l=o.call(this.hls,e,r,t);if(!l)throw new Error("Invalid response from configured generateRequest filter");e=l.initDataType,r=t.decryptdata.pssh=l.initData?new Uint8Array(l.initData):null}catch(t){var u;if(this.warn(t.message),null!=(u=this.hls)&&u.config.debug)throw t}if(null===r)return this.log('Skipping key-session request for "'+i+'" (no initData)'),Promise.resolve(t);var h=this.getKeyIdString(t.decryptdata);this.log('Generating key-session request for "'+i+'": '+h+" (init data type: "+e+" length: "+(r?r.byteLength:null)+")");var d=new Mn,c=t._onmessage=function(e){var r=t.mediaKeysSession;if(r){var i=e.messageType,n=e.message;s.log('"'+i+'" message event for session "'+r.sessionId+'" message size: '+n.byteLength),"license-request"===i||"license-renewal"===i?s.renewLicense(t,n).catch((function(t){s.handleError(t),d.emit("error",t)})):"license-release"===i?t.keySystem===q.FAIRPLAY&&(s.updateKeySession(t,W("acknowledged")),s.removeSession(t)):s.warn('unhandled media key message type "'+i+'"')}else d.emit("error",new Error("invalid state"))},f=t._onkeystatuseschange=function(e){if(t.mediaKeysSession){s.onKeyStatusChange(t);var r=t.keyStatus;d.emit("keyStatus",r),"expired"===r&&(s.warn(t.keySystem+" expired for key "+h),s.renewKeySession(t))}else d.emit("error",new Error("invalid state"))};t.mediaKeysSession.addEventListener("message",c),t.mediaKeysSession.addEventListener("keystatuseschange",f);var g=new Promise((function(t,e){d.on("error",e),d.on("keyStatus",(function(r){r.startsWith("usable")?t():"output-restricted"===r?e(new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED,fatal:!1},"HDCP level output restricted")):"internal-error"===r?e(new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_STATUS_INTERNAL_ERROR,fatal:!0},'key status changed to "'+r+'"')):"expired"===r?e(new Error("key expired while generating request")):s.warn('unhandled key status change "'+r+'"')}))}));return t.mediaKeysSession.generateRequest(e,r).then((function(){var e;s.log('Request generated for key-session "'+(null==(e=t.mediaKeysSession)?void 0:e.sessionId)+'" keyId: '+h)})).catch((function(t){throw new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_NO_SESSION,error:t,fatal:!1},"Error generating key-session request: "+t)})).then((function(){return g})).catch((function(e){throw d.removeAllListeners(),s.removeSession(t),e})).then((function(){return d.removeAllListeners(),t}))},e.onKeyStatusChange=function(t){var e=this;t.mediaKeysSession.keyStatuses.forEach((function(r,i){e.log('key status change "'+r+'" for keyStatuses keyId: '+Lt("buffer"in i?new Uint8Array(i.buffer,i.byteOffset,i.byteLength):new Uint8Array(i))+" session keyId: "+Lt(new Uint8Array(t.decryptdata.keyId||[]))+" uri: "+t.decryptdata.uri),t.keyStatus=r}))},e.fetchServerCertificate=function(t){var e=this.config,r=new(0,e.loader)(e),n=this.getServerCertificateUrl(t);return n?(this.log('Fetching server certificate for "'+t+'"'),new Promise((function(a,s){var o={responseType:"arraybuffer",url:n},l=e.certLoadPolicy.default,u={loadPolicy:l,timeout:l.maxLoadTimeMs,maxRetry:0,retryDelay:0,maxRetryDelay:0},h={onSuccess:function(t,e,r,i){a(t.data)},onError:function(e,r,a,l){s(new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED,fatal:!0,networkDetails:a,response:i({url:o.url,data:void 0},e)},'"'+t+'" certificate request failed ('+n+"). Status: "+e.code+" ("+e.text+")"))},onTimeout:function(e,r,i){s(new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED,fatal:!0,networkDetails:i,response:{url:o.url,data:void 0}},'"'+t+'" certificate request timed out ('+n+")"))},onAbort:function(t,e,r){s(new Error("aborted"))}};r.load(o,u,h)}))):Promise.resolve()},e.setMediaKeysServerCertificate=function(t,e,r){var i=this;return new Promise((function(n,a){t.setServerCertificate(r).then((function(a){i.log("setServerCertificate "+(a?"success":"not supported by CDM")+" ("+(null==r?void 0:r.byteLength)+') on "'+e+'"'),n(t)})).catch((function(t){a(new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED,error:t,fatal:!0},t.message))}))}))},e.renewLicense=function(t,e){var r=this;return this.requestLicense(t,new Uint8Array(e)).then((function(e){return r.updateKeySession(t,new Uint8Array(e)).catch((function(t){throw new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_SESSION_UPDATE_FAILED,error:t,fatal:!0},t.message)}))}))},e.unpackPlayReadyKeyMessage=function(t,e){var r=String.fromCharCode.apply(null,new Uint16Array(e.buffer));if(!r.includes("PlayReadyKeyMessage"))return t.setRequestHeader("Content-Type","text/xml; charset=utf-8"),e;var i=(new DOMParser).parseFromString(r,"application/xml"),n=i.querySelectorAll("HttpHeader");if(n.length>0)for(var a,s=0,o=n.length;s<o;s++){var l,u,h=null==(l=(a=n[s]).querySelector("name"))?void 0:l.textContent,d=null==(u=a.querySelector("value"))?void 0:u.textContent;h&&d&&t.setRequestHeader(h,d)}var c=i.querySelector("Challenge"),f=null==c?void 0:c.textContent;if(!f)throw new Error("Cannot find <Challenge> in key message");return W(atob(f))},e.setupLicenseXHR=function(t,e,r,i){var n=this,a=this.config.licenseXhrSetup;return a?Promise.resolve().then((function(){if(!r.decryptdata)throw new Error("Key removed");return a.call(n.hls,t,e,r,i)})).catch((function(s){if(!r.decryptdata)throw s;return t.open("POST",e,!0),a.call(n.hls,t,e,r,i)})).then((function(r){return t.readyState||t.open("POST",e,!0),{xhr:t,licenseChallenge:r||i}})):(t.open("POST",e,!0),Promise.resolve({xhr:t,licenseChallenge:i}))},e.requestLicense=function(t,e){var r=this,i=this.config.keyLoadPolicy.default;return new Promise((function(n,a){var s=r.getLicenseServerUrl(t.keySystem);r.log("Sending license request to URL: "+s);var o=new XMLHttpRequest;o.responseType="arraybuffer",o.onreadystatechange=function(){if(!r.hls||!t.mediaKeysSession)return a(new Error("invalid state"));if(4===o.readyState)if(200===o.status){r._requestLicenseFailureCount=0;var l=o.response;r.log("License received "+(l instanceof ArrayBuffer?l.byteLength:l));var u=r.config.licenseResponseCallback;if(u)try{l=u.call(r.hls,o,s,t)}catch(t){r.error(t)}n(l)}else{var h=i.errorRetry,d=h?h.maxNumRetry:0;if(r._requestLicenseFailureCount++,r._requestLicenseFailureCount>d||o.status>=400&&o.status<500)a(new es({type:L.KEY_SYSTEM_ERROR,details:A.KEY_SYSTEM_LICENSE_REQUEST_FAILED,fatal:!0,networkDetails:o,response:{url:s,data:void 0,code:o.status,text:o.statusText}},"License Request XHR failed ("+s+"). Status: "+o.status+" ("+o.statusText+")"));else{var c=d-r._requestLicenseFailureCount+1;r.warn("Retrying license request, "+c+" attempts left"),r.requestLicense(t,e).then(n,a)}}},t.licenseXhr&&t.licenseXhr.readyState!==XMLHttpRequest.DONE&&t.licenseXhr.abort(),t.licenseXhr=o,r.setupLicenseXHR(o,s,t,e).then((function(e){var i=e.xhr,n=e.licenseChallenge;t.keySystem==q.PLAYREADY&&(n=r.unpackPlayReadyKeyMessage(i,n)),i.send(n)}))}))},e.onMediaAttached=function(t,e){if(this.config.emeEnabled){var r=e.media;this.media=r,r.addEventListener("encrypted",this.onMediaEncrypted),r.addEventListener("waitingforkey",this.onWaitingForKey)}},e.onMediaDetached=function(){var e=this,r=this.media,i=this.mediaKeySessions;r&&(r.removeEventListener("encrypted",this.onMediaEncrypted),r.removeEventListener("waitingforkey",this.onWaitingForKey),this.media=null),this._requestLicenseFailureCount=0,this.setMediaKeysQueue=[],this.mediaKeySessions=[],this.keyIdToKeySessionPromise={},qt.clearKeyUriToKeyIdMap();var n=i.length;t.CDMCleanupPromise=Promise.all(i.map((function(t){return e.removeSession(t)})).concat(null==r?void 0:r.setMediaKeys(null).catch((function(t){e.log("Could not clear media keys: "+t)})))).then((function(){n&&(e.log("finished closing key sessions and clearing media keys"),i.length=0)})).catch((function(t){e.log("Could not close sessions and clear media keys: "+t)}))},e.onManifestLoading=function(){this.keyFormatPromise=null},e.onManifestLoaded=function(t,e){var r=e.sessionKeys;if(r&&this.config.emeEnabled&&!this.keyFormatPromise){var i=r.reduce((function(t,e){return-1===t.indexOf(e.keyFormat)&&t.push(e.keyFormat),t}),[]);this.log("Selecting key-system from session-keys "+i.join(", ")),this.keyFormatPromise=this.getKeyFormatPromise(i)}},e.removeSession=function(t){var e=this,r=t.mediaKeysSession,i=t.licenseXhr;if(r){this.log("Remove licenses and keys and close session "+r.sessionId),t._onmessage&&(r.removeEventListener("message",t._onmessage),t._onmessage=void 0),t._onkeystatuseschange&&(r.removeEventListener("keystatuseschange",t._onkeystatuseschange),t._onkeystatuseschange=void 0),i&&i.readyState!==XMLHttpRequest.DONE&&i.abort(),t.mediaKeysSession=t.decryptdata=t.licenseXhr=void 0;var n=this.mediaKeySessions.indexOf(t);return n>-1&&this.mediaKeySessions.splice(n,1),r.remove().catch((function(t){e.log("Could not remove session: "+t)})).then((function(){return r.close()})).catch((function(t){e.log("Could not close session: "+t)}))}},t}();Qa.CDMCleanupPromise=void 0;var Ja,$a,Za,ts,es=function(t){function e(e,r){var i;return(i=t.call(this,r)||this).data=void 0,e.error||(e.error=new Error(r)),i.data=e,e.err=e.error,i}return l(e,t),e}(c(Error));!function(t){t.MANIFEST="m",t.AUDIO="a",t.VIDEO="v",t.MUXED="av",t.INIT="i",t.CAPTION="c",t.TIMED_TEXT="tt",t.KEY="k",t.OTHER="o"}(Ja||(Ja={})),function(t){t.DASH="d",t.HLS="h",t.SMOOTH="s",t.OTHER="o"}($a||($a={})),function(t){t.OBJECT="CMCD-Object",t.REQUEST="CMCD-Request",t.SESSION="CMCD-Session",t.STATUS="CMCD-Status"}(Za||(Za={}));var rs=((ts={})[Za.OBJECT]=["br","d","ot","tb"],ts[Za.REQUEST]=["bl","dl","mtp","nor","nrr","su"],ts[Za.SESSION]=["cid","pr","sf","sid","st","v"],ts[Za.STATUS]=["bs","rtp"],ts),is=function t(e,r){this.value=void 0,this.params=void 0,Array.isArray(e)&&(e=e.map((function(e){return e instanceof t?e:new t(e)}))),this.value=e,this.params=r},ns=function(t){this.description=void 0,this.description=t},as="Dict";function ss(t,e,r,i){return new Error("failed to "+t+' "'+(n=e,(Array.isArray(n)?JSON.stringify(n):n instanceof Map?"Map{}":n instanceof Set?"Set{}":"object"==typeof n?JSON.stringify(n):String(n))+'" as ')+r,{cause:i});var n}var os="Bare Item",ls="Boolean",us="Byte Sequence",hs="Decimal",ds="Integer",cs=/[\x00-\x1f\x7f]+/,fs="Token",gs="Key";function vs(t,e,r){return ss("serialize",t,e,r)}function ms(t){if(!1===ArrayBuffer.isView(t))throw vs(t,us);return":"+(e=t,btoa(String.fromCharCode.apply(String,e))+":");var e}function ps(t){if(function(t){return t<-999999999999999||999999999999999<t}(t))throw vs(t,ds);return t.toString()}function ys(t,e){if(t<0)return-ys(-t,e);var r=Math.pow(10,e);if(Math.abs(t*r%1-.5)<Number.EPSILON){var i=Math.floor(t*r);return(i%2==0?i:i+1)/r}return Math.round(t*r)/r}function Es(t){var e=ys(t,3);if(Math.floor(Math.abs(e)).toString().length>12)throw vs(t,hs);var r=e.toString();return r.includes(".")?r:r+".0"}var Ts="String";function Ss(t){var e,r=(e=t).description||e.toString().slice(7,-1);if(!1===/^([a-zA-Z*])([!#$%&'*+\-.^_`|~\w:/]*)$/.test(r))throw vs(r,fs);return r}function Ls(t){switch(typeof t){case"number":if(!y(t))throw vs(t,os);return Number.isInteger(t)?ps(t):Es(t);case"string":return function(t){if(cs.test(t))throw vs(t,Ts);return'"'+t.replace(/\\/g,"\\\\").replace(/"/g,'\\"')+'"'}(t);case"symbol":return Ss(t);case"boolean":return function(t){if("boolean"!=typeof t)throw vs(t,ls);return t?"?1":"?0"}(t);case"object":if(t instanceof Date)return function(t){return"@"+ps(t.getTime()/1e3)}(t);if(t instanceof Uint8Array)return ms(t);if(t instanceof ns)return Ss(t);default:throw vs(t,os)}}function As(t){if(!1===/^[a-z*][a-z0-9\-_.*]*$/.test(t))throw vs(t,gs);return t}function Rs(t){return null==t?"":Object.entries(t).map((function(t){var e=t[0],r=t[1];return!0===r?";"+As(e):";"+As(e)+"="+Ls(r)})).join("")}function ks(t){return t instanceof is?""+Ls(t.value)+Rs(t.params):Ls(t)}function bs(t,e){var r;if(void 0===e&&(e={whitespace:!0}),"object"!=typeof t)throw vs(t,as);var i=t instanceof Map?t.entries():Object.entries(t),n=null!=(r=e)&&r.whitespace?" ":"";return Array.from(i).map((function(t){var e=t[0],r=t[1];r instanceof is==0&&(r=new is(r));var i,n=As(e);return!0===r.value?n+=Rs(r.params):(n+="=",Array.isArray(r.value)?n+="("+(i=r).value.map(ks).join(" ")+")"+Rs(i.params):n+=ks(r)),n})).join(","+n)}var Ds=function(t){return"ot"===t||"sf"===t||"st"===t},Is=function(t){return"number"==typeof t?y(t):null!=t&&""!==t&&!1!==t},ws=function(t){return Math.round(t)},Cs=function(t){return 100*ws(t/100)},_s={br:ws,d:ws,bl:Cs,dl:Cs,mtp:Cs,nor:function(t,e){return null!=e&&e.baseUrl&&(t=function(t,e){var r=new URL(t),i=new URL(e);if(r.origin!==i.origin)return t;for(var n=r.pathname.split("/").slice(1),a=i.pathname.split("/").slice(1,-1);n[0]===a[0];)n.shift(),a.shift();for(;a.length;)a.shift(),n.unshift("..");return n.join("/")}(t,e.baseUrl)),encodeURIComponent(t)},rtp:Cs,tb:ws};function xs(t,e){return void 0===e&&(e={}),t?function(t,e){return bs(t,e)}(function(t,e){var r={};if(null==t||"object"!=typeof t)return r;var i=Object.keys(t).sort(),n=o({},_s,null==e?void 0:e.formatters),a=null==e?void 0:e.filter;return i.forEach((function(i){if(null==a||!a(i)){var s=t[i],o=n[i];o&&(s=o(s,e)),"v"===i&&1===s||"pr"==i&&1===s||Is(s)&&(Ds(i)&&"string"==typeof s&&(s=new ns(s)),r[i]=s)}})),r}(t,e),o({whitespace:!1},e)):""}function Ps(t,e,r){return o(t,function(t,e){var r;if(void 0===e&&(e={}),!t)return{};var i=Object.entries(t),n=Object.entries(rs).concat(Object.entries((null==(r=e)?void 0:r.customHeaderMap)||{})),a=i.reduce((function(t,e){var r,i=e[0],a=e[1],s=(null==(r=n.find((function(t){return t[1].includes(i)})))?void 0:r[0])||Za.REQUEST;return null!=t[s]||(t[s]={}),t[s][i]=a,t}),{});return Object.entries(a).reduce((function(t,r){var i=r[0],n=r[1];return t[i]=xs(n,e),t}),{})}(e,r))}var Fs="CMCD",Ms=/CMCD=[^&#]+/;function Os(t,e,r){var i=function(t,e){if(void 0===e&&(e={}),!t)return"";var r=xs(t,e);return Fs+"="+encodeURIComponent(r)}(e,r);if(!i)return t;if(Ms.test(t))return t.replace(Ms,i);var n=t.includes("?")?"&":"?";return""+t+n+i}var Ns=function(){function t(t){var e=this;this.hls=void 0,this.config=void 0,this.media=void 0,this.sid=void 0,this.cid=void 0,this.useHeaders=!1,this.includeKeys=void 0,this.initialized=!1,this.starved=!1,this.buffering=!0,this.audioBuffer=void 0,this.videoBuffer=void 0,this.onWaiting=function(){e.initialized&&(e.starved=!0),e.buffering=!0},this.onPlaying=function(){e.initialized||(e.initialized=!0),e.buffering=!1},this.applyPlaylistData=function(t){try{e.apply(t,{ot:Ja.MANIFEST,su:!e.initialized})}catch(t){w.warn("Could not generate manifest CMCD data.",t)}},this.applyFragmentData=function(t){try{var r=t.frag,i=e.hls.levels[r.level],n=e.getObjectType(r),a={d:1e3*r.duration,ot:n};n!==Ja.VIDEO&&n!==Ja.AUDIO&&n!=Ja.MUXED||(a.br=i.bitrate/1e3,a.tb=e.getTopBandwidth(n)/1e3,a.bl=e.getBufferLength(n)),e.apply(t,a)}catch(t){w.warn("Could not generate segment CMCD data.",t)}},this.hls=t;var r=this.config=t.config,i=r.cmcd;null!=i&&(r.pLoader=this.createPlaylistLoader(),r.fLoader=this.createFragmentLoader(),this.sid=i.sessionId||function(){try{return crypto.randomUUID()}catch(i){try{var t=URL.createObjectURL(new Blob),e=t.toString();return URL.revokeObjectURL(t),e.slice(e.lastIndexOf("/")+1)}catch(t){var r=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){var e=(r+16*Math.random())%16|0;return r=Math.floor(r/16),("x"==t?e:3&e|8).toString(16)}))}}}(),this.cid=i.contentId,this.useHeaders=!0===i.useHeaders,this.includeKeys=i.includeKeys,this.registerListeners())}var e=t.prototype;return e.registerListeners=function(){var t=this.hls;t.on(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.on(S.MEDIA_DETACHED,this.onMediaDetached,this),t.on(S.BUFFER_CREATED,this.onBufferCreated,this)},e.unregisterListeners=function(){var t=this.hls;t.off(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.off(S.MEDIA_DETACHED,this.onMediaDetached,this),t.off(S.BUFFER_CREATED,this.onBufferCreated,this)},e.destroy=function(){this.unregisterListeners(),this.onMediaDetached(),this.hls=this.config=this.audioBuffer=this.videoBuffer=null,this.onWaiting=this.onPlaying=null},e.onMediaAttached=function(t,e){this.media=e.media,this.media.addEventListener("waiting",this.onWaiting),this.media.addEventListener("playing",this.onPlaying)},e.onMediaDetached=function(){this.media&&(this.media.removeEventListener("waiting",this.onWaiting),this.media.removeEventListener("playing",this.onPlaying),this.media=null)},e.onBufferCreated=function(t,e){var r,i;this.audioBuffer=null==(r=e.tracks.audio)?void 0:r.buffer,this.videoBuffer=null==(i=e.tracks.video)?void 0:i.buffer},e.createData=function(){var t;return{v:1,sf:$a.HLS,sid:this.sid,cid:this.cid,pr:null==(t=this.media)?void 0:t.playbackRate,mtp:this.hls.bandwidthEstimate/1e3}},e.apply=function(t,e){void 0===e&&(e={}),o(e,this.createData());var r=e.ot===Ja.INIT||e.ot===Ja.VIDEO||e.ot===Ja.MUXED;this.starved&&r&&(e.bs=!0,e.su=!0,this.starved=!1),null==e.su&&(e.su=this.buffering);var i=this.includeKeys;i&&(e=Object.keys(e).reduce((function(t,r){return i.includes(r)&&(t[r]=e[r]),t}),{})),this.useHeaders?(t.headers||(t.headers={}),Ps(t.headers,e)):t.url=Os(t.url,e)},e.getObjectType=function(t){var e=t.type;return"subtitle"===e?Ja.TIMED_TEXT:"initSegment"===t.sn?Ja.INIT:"audio"===e?Ja.AUDIO:"main"===e?this.hls.audioTracks.length?Ja.VIDEO:Ja.MUXED:void 0},e.getTopBandwidth=function(t){var e,r=0,i=this.hls;if(t===Ja.AUDIO)e=i.audioTracks;else{var n=i.maxAutoLevel,a=n>-1?n+1:i.levels.length;e=i.levels.slice(0,a)}for(var s,o=g(e);!(s=o()).done;){var l=s.value;l.bitrate>r&&(r=l.bitrate)}return r>0?r:NaN},e.getBufferLength=function(t){var e=this.hls.media,r=t===Ja.AUDIO?this.audioBuffer:this.videoBuffer;return r&&e?1e3*zr.bufferInfo(r,e.currentTime,this.config.maxBufferHole).len:NaN},e.createPlaylistLoader=function(){var t=this.config.pLoader,e=this.applyPlaylistData,r=t||this.config.loader;return function(){function t(t){this.loader=void 0,this.loader=new r(t)}var i=t.prototype;return i.destroy=function(){this.loader.destroy()},i.abort=function(){this.loader.abort()},i.load=function(t,r,i){e(t),this.loader.load(t,r,i)},s(t,[{key:"stats",get:function(){return this.loader.stats}},{key:"context",get:function(){return this.loader.context}}]),t}()},e.createFragmentLoader=function(){var t=this.config.fLoader,e=this.applyFragmentData,r=t||this.config.loader;return function(){function t(t){this.loader=void 0,this.loader=new r(t)}var i=t.prototype;return i.destroy=function(){this.loader.destroy()},i.abort=function(){this.loader.abort()},i.load=function(t,r,i){e(t),this.loader.load(t,r,i)},s(t,[{key:"stats",get:function(){return this.loader.stats}},{key:"context",get:function(){return this.loader.context}}]),t}()},t}(),Us=function(){function t(t){this.hls=void 0,this.log=void 0,this.loader=null,this.uri=null,this.pathwayId=".",this.pathwayPriority=null,this.timeToLoad=300,this.reloadTimer=-1,this.updated=0,this.started=!1,this.enabled=!0,this.levels=null,this.audioTracks=null,this.subtitleTracks=null,this.penalizedPathways={},this.hls=t,this.log=w.log.bind(w,"[content-steering]:"),this.registerListeners()}var e=t.prototype;return e.registerListeners=function(){var t=this.hls;t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.MANIFEST_LOADED,this.onManifestLoaded,this),t.on(S.MANIFEST_PARSED,this.onManifestParsed,this),t.on(S.ERROR,this.onError,this)},e.unregisterListeners=function(){var t=this.hls;t&&(t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.MANIFEST_LOADED,this.onManifestLoaded,this),t.off(S.MANIFEST_PARSED,this.onManifestParsed,this),t.off(S.ERROR,this.onError,this))},e.startLoad=function(){if(this.started=!0,this.clearTimeout(),this.enabled&&this.uri){if(this.updated){var t=1e3*this.timeToLoad-(performance.now()-this.updated);if(t>0)return void this.scheduleRefresh(this.uri,t)}this.loadSteeringManifest(this.uri)}},e.stopLoad=function(){this.started=!1,this.loader&&(this.loader.destroy(),this.loader=null),this.clearTimeout()},e.clearTimeout=function(){-1!==this.reloadTimer&&(self.clearTimeout(this.reloadTimer),this.reloadTimer=-1)},e.destroy=function(){this.unregisterListeners(),this.stopLoad(),this.hls=null,this.levels=this.audioTracks=this.subtitleTracks=null},e.removeLevel=function(t){var e=this.levels;e&&(this.levels=e.filter((function(e){return e!==t})))},e.onManifestLoading=function(){this.stopLoad(),this.enabled=!0,this.timeToLoad=300,this.updated=0,this.uri=null,this.pathwayId=".",this.levels=this.audioTracks=this.subtitleTracks=null},e.onManifestLoaded=function(t,e){var r=e.contentSteering;null!==r&&(this.pathwayId=r.pathwayId,this.uri=r.uri,this.started&&this.startLoad())},e.onManifestParsed=function(t,e){this.audioTracks=e.audioTracks,this.subtitleTracks=e.subtitleTracks},e.onError=function(t,e){var r=e.errorAction;if((null==r?void 0:r.action)===Tr&&r.flags===Rr){var i=this.levels,n=this.pathwayPriority,a=this.pathwayId;if(e.context){var s=e.context,o=s.groupId,l=s.pathwayId,u=s.type;o&&i?a=this.getPathwayForGroupId(o,u,a):l&&(a=l)}a in this.penalizedPathways||(this.penalizedPathways[a]=performance.now()),!n&&i&&(n=i.reduce((function(t,e){return-1===t.indexOf(e.pathwayId)&&t.push(e.pathwayId),t}),[])),n&&n.length>1&&(this.updatePathwayPriority(n),r.resolved=this.pathwayId!==a),r.resolved||w.warn("Could not resolve "+e.details+' ("'+e.error.message+'") with content-steering for Pathway: '+a+" levels: "+(i?i.length:i)+" priorities: "+JSON.stringify(n)+" penalized: "+JSON.stringify(this.penalizedPathways))}},e.filterParsedLevels=function(t){this.levels=t;var e=this.getLevelsForPathway(this.pathwayId);if(0===e.length){var r=t[0].pathwayId;this.log("No levels found in Pathway "+this.pathwayId+'. Setting initial Pathway to "'+r+'"'),e=this.getLevelsForPathway(r),this.pathwayId=r}return e.length!==t.length?(this.log("Found "+e.length+"/"+t.length+' levels in Pathway "'+this.pathwayId+'"'),e):t},e.getLevelsForPathway=function(t){return null===this.levels?[]:this.levels.filter((function(e){return t===e.pathwayId}))},e.updatePathwayPriority=function(t){var e;this.pathwayPriority=t;var r=this.penalizedPathways,i=performance.now();Object.keys(r).forEach((function(t){i-r[t]>3e5&&delete r[t]}));for(var n=0;n<t.length;n++){var a=t[n];if(!(a in r)){if(a===this.pathwayId)return;var s=this.hls.nextLoadLevel,o=this.hls.levels[s];if((e=this.getLevelsForPathway(a)).length>0){this.log('Setting Pathway to "'+a+'"'),this.pathwayId=a,ur(e),this.hls.trigger(S.LEVELS_UPDATED,{levels:e});var l=this.hls.levels[s];o&&l&&this.levels&&(l.attrs["STABLE-VARIANT-ID"]!==o.attrs["STABLE-VARIANT-ID"]&&l.bitrate!==o.bitrate&&this.log("Unstable Pathways change from bitrate "+o.bitrate+" to "+l.bitrate),this.hls.nextLoadLevel=s);break}}}},e.getPathwayForGroupId=function(t,e,r){for(var i=this.getLevelsForPathway(r).concat(this.levels||[]),n=0;n<i.length;n++)if(e===be&&i[n].hasAudioGroup(t)||e===De&&i[n].hasSubtitleGroup(t))return i[n].pathwayId;return r},e.clonePathways=function(t){var e=this,r=this.levels;if(r){var i={},n={};t.forEach((function(t){var a=t.ID,s=t["BASE-ID"],o=t["URI-REPLACEMENT"];if(!r.some((function(t){return t.pathwayId===a}))){var l=e.getLevelsForPathway(s).map((function(t){var e=new x(t.attrs);e["PATHWAY-ID"]=a;var r=e.AUDIO&&e.AUDIO+"_clone_"+a,s=e.SUBTITLES&&e.SUBTITLES+"_clone_"+a;r&&(i[e.AUDIO]=r,e.AUDIO=r),s&&(n[e.SUBTITLES]=s,e.SUBTITLES=s);var l=Gs(t.uri,e["STABLE-VARIANT-ID"],"PER-VARIANT-URIS",o),u=new tr({attrs:e,audioCodec:t.audioCodec,bitrate:t.bitrate,height:t.height,name:t.name,url:l,videoCodec:t.videoCodec,width:t.width});if(t.audioGroups)for(var h=1;h<t.audioGroups.length;h++)u.addGroupId("audio",t.audioGroups[h]+"_clone_"+a);if(t.subtitleGroups)for(var d=1;d<t.subtitleGroups.length;d++)u.addGroupId("text",t.subtitleGroups[d]+"_clone_"+a);return u}));r.push.apply(r,l),Bs(e.audioTracks,i,o,a),Bs(e.subtitleTracks,n,o,a)}}))}},e.loadSteeringManifest=function(t){var e,r=this,i=this.hls.config,n=i.loader;this.loader&&this.loader.destroy(),this.loader=new n(i);try{e=new self.URL(t)}catch(e){return this.enabled=!1,void this.log("Failed to parse Steering Manifest URI: "+t)}if("data:"!==e.protocol){var a=0|(this.hls.bandwidthEstimate||i.abrEwmaDefaultEstimate);e.searchParams.set("_HLS_pathway",this.pathwayId),e.searchParams.set("_HLS_throughput",""+a)}var s={responseType:"json",url:e.href},o=i.steeringManifestLoadPolicy.default,l=o.errorRetry||o.timeoutRetry||{},u={loadPolicy:o,timeout:o.maxLoadTimeMs,maxRetry:l.maxNumRetry||0,retryDelay:l.retryDelayMs||0,maxRetryDelay:l.maxRetryDelayMs||0},h={onSuccess:function(t,i,n,a){r.log('Loaded steering manifest: "'+e+'"');var s=t.data;if(1===s.VERSION){r.updated=performance.now(),r.timeToLoad=s.TTL;var o=s["RELOAD-URI"],l=s["PATHWAY-CLONES"],u=s["PATHWAY-PRIORITY"];if(o)try{r.uri=new self.URL(o,e).href}catch(t){return r.enabled=!1,void r.log("Failed to parse Steering Manifest RELOAD-URI: "+o)}r.scheduleRefresh(r.uri||n.url),l&&r.clonePathways(l);var h={steeringManifest:s,url:e.toString()};r.hls.trigger(S.STEERING_MANIFEST_LOADED,h),u&&r.updatePathwayPriority(u)}else r.log("Steering VERSION "+s.VERSION+" not supported!")},onError:function(t,e,i,n){if(r.log("Error loading steering manifest: "+t.code+" "+t.text+" ("+e.url+")"),r.stopLoad(),410===t.code)return r.enabled=!1,void r.log("Steering manifest "+e.url+" no longer available");var a=1e3*r.timeToLoad;if(429!==t.code)r.scheduleRefresh(r.uri||e.url,a);else{var s=r.loader;if("function"==typeof(null==s?void 0:s.getResponseHeader)){var o=s.getResponseHeader("Retry-After");o&&(a=1e3*parseFloat(o))}r.log("Steering manifest "+e.url+" rate limited")}},onTimeout:function(t,e,i){r.log("Timeout loading steering manifest ("+e.url+")"),r.scheduleRefresh(r.uri||e.url)}};this.log("Requesting steering manifest: "+e),this.loader.load(s,u,h)},e.scheduleRefresh=function(t,e){var r=this;void 0===e&&(e=1e3*this.timeToLoad),this.clearTimeout(),this.reloadTimer=self.setTimeout((function(){var e,i=null==(e=r.hls)?void 0:e.media;!i||i.ended?r.scheduleRefresh(t,1e3*r.timeToLoad):r.loadSteeringManifest(t)}),e)},t}();function Bs(t,e,r,i){t&&Object.keys(e).forEach((function(n){var a=t.filter((function(t){return t.groupId===n})).map((function(t){var a=o({},t);return a.details=void 0,a.attrs=new x(a.attrs),a.url=a.attrs.URI=Gs(t.url,t.attrs["STABLE-RENDITION-ID"],"PER-RENDITION-URIS",r),a.groupId=a.attrs["GROUP-ID"]=e[n],a.attrs["PATHWAY-ID"]=i,a}));t.push.apply(t,a)}))}function Gs(t,e,r,i){var n,a=i.HOST,s=i.PARAMS,o=i[r];e&&(n=null==o?void 0:o[e])&&(t=n);var l=new self.URL(t);return a&&!n&&(l.host=a),s&&Object.keys(s).sort().forEach((function(t){t&&l.searchParams.set(t,s[t])})),l.href}var Ks=/^age:\s*[\d.]+\s*$/im,Hs=function(){function t(t){this.xhrSetup=void 0,this.requestTimeout=void 0,this.retryTimeout=void 0,this.retryDelay=void 0,this.config=null,this.callbacks=null,this.context=null,this.loader=null,this.stats=void 0,this.xhrSetup=t&&t.xhrSetup||null,this.stats=new M,this.retryDelay=0}var e=t.prototype;return e.destroy=function(){this.callbacks=null,this.abortInternal(),this.loader=null,this.config=null,this.context=null,this.xhrSetup=null,this.stats=null},e.abortInternal=function(){var t=this.loader;self.clearTimeout(this.requestTimeout),self.clearTimeout(this.retryTimeout),t&&(t.onreadystatechange=null,t.onprogress=null,4!==t.readyState&&(this.stats.aborted=!0,t.abort()))},e.abort=function(){var t;this.abortInternal(),null!=(t=this.callbacks)&&t.onAbort&&this.callbacks.onAbort(this.stats,this.context,this.loader)},e.load=function(t,e,r){if(this.stats.loading.start)throw new Error("Loader can only be used once.");this.stats.loading.start=self.performance.now(),this.context=t,this.config=e,this.callbacks=r,this.loadInternal()},e.loadInternal=function(){var t=this,e=this.config,r=this.context;if(e&&r){var i=this.loader=new self.XMLHttpRequest,n=this.stats;n.loading.first=0,n.loaded=0,n.aborted=!1;var a=this.xhrSetup;a?Promise.resolve().then((function(){if(!t.stats.aborted)return a(i,r.url)})).catch((function(t){return i.open("GET",r.url,!0),a(i,r.url)})).then((function(){t.stats.aborted||t.openAndSendXhr(i,r,e)})).catch((function(e){t.callbacks.onError({code:i.status,text:e.message},r,i,n)})):this.openAndSendXhr(i,r,e)}},e.openAndSendXhr=function(t,e,r){t.readyState||t.open("GET",e.url,!0);var i=e.headers,n=r.loadPolicy,a=n.maxTimeToFirstByteMs,s=n.maxLoadTimeMs;if(i)for(var o in i)t.setRequestHeader(o,i[o]);e.rangeEnd&&t.setRequestHeader("Range","bytes="+e.rangeStart+"-"+(e.rangeEnd-1)),t.onreadystatechange=this.readystatechange.bind(this),t.onprogress=this.loadprogress.bind(this),t.responseType=e.responseType,self.clearTimeout(this.requestTimeout),r.timeout=a&&y(a)?a:s,this.requestTimeout=self.setTimeout(this.loadtimeout.bind(this),r.timeout),t.send()},e.readystatechange=function(){var t=this.context,e=this.loader,r=this.stats;if(t&&e){var i=e.readyState,n=this.config;if(!r.aborted&&i>=2&&(0===r.loading.first&&(r.loading.first=Math.max(self.performance.now(),r.loading.start),n.timeout!==n.loadPolicy.maxLoadTimeMs&&(self.clearTimeout(this.requestTimeout),n.timeout=n.loadPolicy.maxLoadTimeMs,this.requestTimeout=self.setTimeout(this.loadtimeout.bind(this),n.loadPolicy.maxLoadTimeMs-(r.loading.first-r.loading.start)))),4===i)){self.clearTimeout(this.requestTimeout),e.onreadystatechange=null,e.onprogress=null;var a=e.status,s="text"!==e.responseType;if(a>=200&&a<300&&(s&&e.response||null!==e.responseText)){r.loading.end=Math.max(self.performance.now(),r.loading.first);var o=s?e.response:e.responseText,l="arraybuffer"===e.responseType?o.byteLength:o.length;if(r.loaded=r.total=l,r.bwEstimate=8e3*r.total/(r.loading.end-r.loading.first),!this.callbacks)return;var u=this.callbacks.onProgress;if(u&&u(r,t,o,e),!this.callbacks)return;var h={url:e.responseURL,data:o,code:a};this.callbacks.onSuccess(h,r,t,e)}else{var d=n.loadPolicy.errorRetry;gr(d,r.retry,!1,{url:t.url,data:void 0,code:a})?this.retry(d):(w.error(a+" while loading "+t.url),this.callbacks.onError({code:a,text:e.statusText},t,e,r))}}}},e.loadtimeout=function(){var t,e=null==(t=this.config)?void 0:t.loadPolicy.timeoutRetry;if(gr(e,this.stats.retry,!0))this.retry(e);else{var r;w.warn("timeout while loading "+(null==(r=this.context)?void 0:r.url));var i=this.callbacks;i&&(this.abortInternal(),i.onTimeout(this.stats,this.context,this.loader))}},e.retry=function(t){var e=this.context,r=this.stats;this.retryDelay=cr(t,r.retry),r.retry++,w.warn((status?"HTTP Status "+status:"Timeout")+" while loading "+(null==e?void 0:e.url)+", retrying "+r.retry+"/"+t.maxNumRetry+" in "+this.retryDelay+"ms"),this.abortInternal(),this.loader=null,self.clearTimeout(this.retryTimeout),this.retryTimeout=self.setTimeout(this.loadInternal.bind(this),this.retryDelay)},e.loadprogress=function(t){var e=this.stats;e.loaded=t.loaded,t.lengthComputable&&(e.total=t.total)},e.getCacheAge=function(){var t=null;if(this.loader&&Ks.test(this.loader.getAllResponseHeaders())){var e=this.loader.getResponseHeader("age");t=e?parseFloat(e):null}return t},e.getResponseHeader=function(t){return this.loader&&new RegExp("^"+t+":\\s*[\\d.]+\\s*$","im").test(this.loader.getAllResponseHeaders())?this.loader.getResponseHeader(t):null},t}(),Vs=/(\d+)-(\d+)\/(\d+)/,Ys=function(){function t(t){this.fetchSetup=void 0,this.requestTimeout=void 0,this.request=null,this.response=null,this.controller=void 0,this.context=null,this.config=null,this.callbacks=null,this.stats=void 0,this.loader=null,this.fetchSetup=t.fetchSetup||Ws,this.controller=new self.AbortController,this.stats=new M}var e=t.prototype;return e.destroy=function(){this.loader=this.callbacks=this.context=this.config=this.request=null,this.abortInternal(),this.response=null,this.fetchSetup=this.controller=this.stats=null},e.abortInternal=function(){this.controller&&!this.stats.loading.end&&(this.stats.aborted=!0,this.controller.abort())},e.abort=function(){var t;this.abortInternal(),null!=(t=this.callbacks)&&t.onAbort&&this.callbacks.onAbort(this.stats,this.context,this.response)},e.load=function(t,e,r){var i=this,n=this.stats;if(n.loading.start)throw new Error("Loader can only be used once.");n.loading.start=self.performance.now();var a=function(t,e){var r={method:"GET",mode:"cors",credentials:"same-origin",signal:e,headers:new self.Headers(o({},t.headers))};return t.rangeEnd&&r.headers.set("Range","bytes="+t.rangeStart+"-"+String(t.rangeEnd-1)),r}(t,this.controller.signal),s=r.onProgress,l="arraybuffer"===t.responseType,u=l?"byteLength":"length",h=e.loadPolicy,d=h.maxTimeToFirstByteMs,c=h.maxLoadTimeMs;this.context=t,this.config=e,this.callbacks=r,this.request=this.fetchSetup(t,a),self.clearTimeout(this.requestTimeout),e.timeout=d&&y(d)?d:c,this.requestTimeout=self.setTimeout((function(){i.abortInternal(),r.onTimeout(n,t,i.response)}),e.timeout),self.fetch(this.request).then((function(a){i.response=i.loader=a;var o=Math.max(self.performance.now(),n.loading.start);if(self.clearTimeout(i.requestTimeout),e.timeout=c,i.requestTimeout=self.setTimeout((function(){i.abortInternal(),r.onTimeout(n,t,i.response)}),c-(o-n.loading.start)),!a.ok){var u=a.status,h=a.statusText;throw new qs(h||"fetch, bad network response",u,a)}return n.loading.first=o,n.total=function(t){var e=t.get("Content-Range");if(e){var r=function(t){var e=Vs.exec(t);if(e)return parseInt(e[2])-parseInt(e[1])+1}(e);if(y(r))return r}var i=t.get("Content-Length");if(i)return parseInt(i)}(a.headers)||n.total,s&&y(e.highWaterMark)?i.loadProgressively(a,n,t,e.highWaterMark,s):l?a.arrayBuffer():"json"===t.responseType?a.json():a.text()})).then((function(a){var o=i.response;if(!o)throw new Error("loader destroyed");self.clearTimeout(i.requestTimeout),n.loading.end=Math.max(self.performance.now(),n.loading.first);var l=a[u];l&&(n.loaded=n.total=l);var h={url:o.url,data:a,code:o.status};s&&!y(e.highWaterMark)&&s(n,t,a,o),r.onSuccess(h,n,t,o)})).catch((function(e){if(self.clearTimeout(i.requestTimeout),!n.aborted){var a=e&&e.code||0,s=e?e.message:null;r.onError({code:a,text:s},t,e?e.details:null,n)}}))},e.getCacheAge=function(){var t=null;if(this.response){var e=this.response.headers.get("age");t=e?parseFloat(e):null}return t},e.getResponseHeader=function(t){return this.response?this.response.headers.get(t):null},e.loadProgressively=function(t,e,r,i,n){void 0===i&&(i=0);var a=new ki,s=t.body.getReader();return function o(){return s.read().then((function(s){if(s.done)return a.dataLength&&n(e,r,a.flush(),t),Promise.resolve(new ArrayBuffer(0));var l=s.value,u=l.length;return e.loaded+=u,u<i||a.dataLength?(a.push(l),a.dataLength>=i&&n(e,r,a.flush(),t)):n(e,r,l,t),o()})).catch((function(){return Promise.reject()}))}()},t}();function Ws(t,e){return new self.Request(t.url,e)}var js,qs=function(t){function e(e,r,i){var n;return(n=t.call(this,e)||this).code=void 0,n.details=void 0,n.code=r,n.details=i,n}return l(e,t),e}(c(Error)),Xs=/\s/,zs=i(i({autoStartLoad:!0,startPosition:-1,defaultAudioCodec:void 0,debug:!1,capLevelOnFPSDrop:!1,capLevelToPlayerSize:!1,ignoreDevicePixelRatio:!1,preferManagedMediaSource:!0,initialLiveManifestSize:1,maxBufferLength:30,backBufferLength:1/0,frontBufferFlushThreshold:1/0,maxBufferSize:6e7,maxBufferHole:.1,highBufferWatchdogPeriod:2,nudgeOffset:.1,nudgeMaxRetry:3,maxFragLookUpTolerance:.25,liveSyncDurationCount:3,liveMaxLatencyDurationCount:1/0,liveSyncDuration:void 0,liveMaxLatencyDuration:void 0,maxLiveSyncPlaybackRate:1,liveDurationInfinity:!1,liveBackBufferLength:null,maxMaxBufferLength:600,enableWorker:!0,workerPath:null,enableSoftwareAES:!0,startLevel:void 0,startFragPrefetch:!1,fpsDroppedMonitoringPeriod:5e3,fpsDroppedMonitoringThreshold:.2,appendErrorMaxRetry:3,loader:Hs,fLoader:void 0,pLoader:void 0,xhrSetup:void 0,licenseXhrSetup:void 0,licenseResponseCallback:void 0,abrController:Br,bufferController:Qn,capLevelController:qa,errorController:br,fpsController:Xa,stretchShortVideoTrack:!1,maxAudioFramesDrift:1,forceKeyFrameOnDiscontinuity:!0,abrEwmaFastLive:3,abrEwmaSlowLive:9,abrEwmaFastVoD:3,abrEwmaSlowVoD:9,abrEwmaDefaultEstimate:5e5,abrEwmaDefaultEstimateMax:5e6,abrBandWidthFactor:.95,abrBandWidthUpFactor:.7,abrMaxWithRealBitrate:!1,maxStarvationDelay:4,maxLoadingDelay:4,minAutoBitrate:0,emeEnabled:!1,widevineLicenseUrl:void 0,drmSystems:{},drmSystemOptions:{},requestMediaKeySystemAccessFunc:it,testBandwidth:!0,progressive:!1,lowLatencyMode:!0,cmcd:void 0,enableDateRangeMetadataCues:!0,enableEmsgMetadataCues:!0,enableID3MetadataCues:!0,useMediaCapabilities:!0,certLoadPolicy:{default:{maxTimeToFirstByteMs:8e3,maxLoadTimeMs:2e4,timeoutRetry:null,errorRetry:null}},keyLoadPolicy:{default:{maxTimeToFirstByteMs:8e3,maxLoadTimeMs:2e4,timeoutRetry:{maxNumRetry:1,retryDelayMs:1e3,maxRetryDelayMs:2e4,backoff:"linear"},errorRetry:{maxNumRetry:8,retryDelayMs:1e3,maxRetryDelayMs:2e4,backoff:"linear"}}},manifestLoadPolicy:{default:{maxTimeToFirstByteMs:1/0,maxLoadTimeMs:2e4,timeoutRetry:{maxNumRetry:2,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:1,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},playlistLoadPolicy:{default:{maxTimeToFirstByteMs:1e4,maxLoadTimeMs:2e4,timeoutRetry:{maxNumRetry:2,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:2,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},fragLoadPolicy:{default:{maxTimeToFirstByteMs:1e4,maxLoadTimeMs:12e4,timeoutRetry:{maxNumRetry:4,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:6,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},steeringManifestLoadPolicy:{default:{maxTimeToFirstByteMs:1e4,maxLoadTimeMs:2e4,timeoutRetry:{maxNumRetry:2,retryDelayMs:0,maxRetryDelayMs:0},errorRetry:{maxNumRetry:1,retryDelayMs:1e3,maxRetryDelayMs:8e3}}},manifestLoadingTimeOut:1e4,manifestLoadingMaxRetry:1,manifestLoadingRetryDelay:1e3,manifestLoadingMaxRetryTimeout:64e3,levelLoadingTimeOut:1e4,levelLoadingMaxRetry:4,levelLoadingRetryDelay:1e3,levelLoadingMaxRetryTimeout:64e3,fragLoadingTimeOut:2e4,fragLoadingMaxRetry:6,fragLoadingRetryDelay:1e3,fragLoadingMaxRetryTimeout:64e3},{cueHandler:{newCue:function(t,e,r,i){for(var n,a,s,o,l,u=[],h=self.VTTCue||self.TextTrackCue,d=0;d<i.rows.length;d++)if(s=!0,o=0,l="",!(n=i.rows[d]).isEmpty()){for(var c,f=0;f<n.chars.length;f++)Xs.test(n.chars[f].uchar)&&s?o++:(l+=n.chars[f].uchar,s=!1);n.cueStartTime=e,e===r&&(r+=1e-4),o>=16?o--:o++;var g=ba(l.trim()),v=_a(e,r,g);null!=t&&null!=(c=t.cues)&&c.getCueById(v)||((a=new h(e,r,g)).id=v,a.line=d+1,a.align="left",a.position=10+Math.min(80,10*Math.floor(8*o/32)),u.push(a))}return t&&u.length&&(u.sort((function(t,e){return"auto"===t.line||"auto"===e.line?0:t.line>8&&e.line>8?e.line-t.line:t.line-e.line})),u.forEach((function(e){return Me(t,e)}))),u}},enableWebVTT:!0,enableIMSC1:!0,enableCEA708Captions:!0,captionsTextTrack1Label:"English",captionsTextTrack1LanguageCode:"en",captionsTextTrack2Label:"Spanish",captionsTextTrack2LanguageCode:"es",captionsTextTrack3Label:"Unknown CC",captionsTextTrack3LanguageCode:"",captionsTextTrack4Label:"Unknown CC",captionsTextTrack4LanguageCode:"",renderTextTracksNatively:!0}),{},{subtitleStreamController:Wn,subtitleTrackController:qn,timelineController:Ya,audioStreamController:Vn,audioTrackController:Yn,emeController:Qa,cmcdController:Ns,contentSteeringController:Us});function Qs(t){return t&&"object"==typeof t?Array.isArray(t)?t.map(Qs):Object.keys(t).reduce((function(e,r){return e[r]=Qs(t[r]),e}),{}):t}function Js(t){var e=t.loader;e!==Ys&&e!==Hs?(w.log("[config]: Custom loader detected, cannot enable progressive streaming"),t.progressive=!1):function(){if(self.fetch&&self.AbortController&&self.ReadableStream&&self.Request)try{return new self.ReadableStream({}),!0}catch(t){}return!1}()&&(t.loader=Ys,t.progressive=!0,t.enableSoftwareAES=!0,w.log("[config]: Progressive streaming enabled, using FetchLoader"))}var $s=function(t){function e(e,r){var i;return(i=t.call(this,e,"[level-controller]")||this)._levels=[],i._firstLevel=-1,i._maxAutoLevel=-1,i._startLevel=void 0,i.currentLevel=null,i.currentLevelIndex=-1,i.manualLevelIndex=-1,i.steering=void 0,i.onParsedComplete=void 0,i.steering=r,i._registerListeners(),i}l(e,t);var r=e.prototype;return r._registerListeners=function(){var t=this.hls;t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.MANIFEST_LOADED,this.onManifestLoaded,this),t.on(S.LEVEL_LOADED,this.onLevelLoaded,this),t.on(S.LEVELS_UPDATED,this.onLevelsUpdated,this),t.on(S.FRAG_BUFFERED,this.onFragBuffered,this),t.on(S.ERROR,this.onError,this)},r._unregisterListeners=function(){var t=this.hls;t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.MANIFEST_LOADED,this.onManifestLoaded,this),t.off(S.LEVEL_LOADED,this.onLevelLoaded,this),t.off(S.LEVELS_UPDATED,this.onLevelsUpdated,this),t.off(S.FRAG_BUFFERED,this.onFragBuffered,this),t.off(S.ERROR,this.onError,this)},r.destroy=function(){this._unregisterListeners(),this.steering=null,this.resetLevels(),t.prototype.destroy.call(this)},r.stopLoad=function(){this._levels.forEach((function(t){t.loadError=0,t.fragmentError=0})),t.prototype.stopLoad.call(this)},r.resetLevels=function(){this._startLevel=void 0,this.manualLevelIndex=-1,this.currentLevelIndex=-1,this.currentLevel=null,this._levels=[],this._maxAutoLevel=-1},r.onManifestLoading=function(t,e){this.resetLevels()},r.onManifestLoaded=function(t,e){var r=this.hls.config.preferManagedMediaSource,i=[],n={},a={},s=!1,o=!1,l=!1;e.levels.forEach((function(t){var e,u,h=t.attrs,d=t.audioCodec,c=t.videoCodec;-1!==(null==(e=d)?void 0:e.indexOf("mp4a.40.34"))&&(js||(js=/chrome|firefox/i.test(navigator.userAgent)),js&&(t.audioCodec=d=void 0)),d&&(t.audioCodec=d=ue(d,r)),0===(null==(u=c)?void 0:u.indexOf("avc1"))&&(c=t.videoCodec=function(t){var e=t.split(".");if(e.length>2){var r=e.shift()+".";return(r+=parseInt(e.shift()).toString(16))+("000"+parseInt(e.shift()).toString(16)).slice(-4)}return t}(c));var f=t.width,g=t.height,v=t.unknownCodecs;if(s||(s=!(!f||!g)),o||(o=!!c),l||(l=!!d),!(null!=v&&v.length||d&&!re(d,"audio",r)||c&&!re(c,"video",r))){var m=h.CODECS,p=h["FRAME-RATE"],y=h["HDCP-LEVEL"],E=h["PATHWAY-ID"],T=h.RESOLUTION,S=h["VIDEO-RANGE"],L=(E||".")+"-"+t.bitrate+"-"+T+"-"+p+"-"+m+"-"+S+"-"+y;if(n[L])if(n[L].uri===t.url||t.attrs["PATHWAY-ID"])n[L].addGroupId("audio",h.AUDIO),n[L].addGroupId("text",h.SUBTITLES);else{var A=a[L]+=1;t.attrs["PATHWAY-ID"]=new Array(A+1).join(".");var R=new tr(t);n[L]=R,i.push(R)}else{var k=new tr(t);n[L]=k,a[L]=1,i.push(k)}}})),this.filterAndSortMediaOptions(i,e,s,o,l)},r.filterAndSortMediaOptions=function(t,e,r,i,n){var a=this,s=[],o=[],l=t;if((r||i)&&n&&(l=l.filter((function(t){var e,r=t.videoCodec,i=t.videoRange,n=t.width,a=t.height;return(!!r||!(!n||!a))&&!!(e=i)&&ze.indexOf(e)>-1}))),0!==l.length){if(e.audioTracks){var u=this.hls.config.preferManagedMediaSource;Zs(s=e.audioTracks.filter((function(t){return!t.audioCodec||re(t.audioCodec,"audio",u)})))}e.subtitles&&Zs(o=e.subtitles);var h=l.slice(0);l.sort((function(t,e){if(t.attrs["HDCP-LEVEL"]!==e.attrs["HDCP-LEVEL"])return(t.attrs["HDCP-LEVEL"]||"")>(e.attrs["HDCP-LEVEL"]||"")?1:-1;if(r&&t.height!==e.height)return t.height-e.height;if(t.frameRate!==e.frameRate)return t.frameRate-e.frameRate;if(t.videoRange!==e.videoRange)return ze.indexOf(t.videoRange)-ze.indexOf(e.videoRange);if(t.videoCodec!==e.videoCodec){var i=ae(t.videoCodec),n=ae(e.videoCodec);if(i!==n)return n-i}if(t.uri===e.uri&&t.codecSet!==e.codecSet){var a=se(t.codecSet),s=se(e.codecSet);if(a!==s)return s-a}return t.bitrate!==e.bitrate?t.bitrate-e.bitrate:0}));var d=h[0];if(this.steering&&(l=this.steering.filterParsedLevels(l)).length!==h.length)for(var c=0;c<h.length;c++)if(h[c].pathwayId===l[0].pathwayId){d=h[c];break}this._levels=l;for(var f=0;f<l.length;f++)if(l[f]===d){var g;this._firstLevel=f;var v=d.bitrate,m=this.hls.bandwidthEstimate;if(this.log("manifest loaded, "+l.length+" level(s) found, first bitrate: "+v),void 0===(null==(g=this.hls.userConfig)?void 0:g.abrEwmaDefaultEstimate)){var p=Math.min(v,this.hls.config.abrEwmaDefaultEstimateMax);p>m&&m===zs.abrEwmaDefaultEstimate&&(this.hls.bandwidthEstimate=p)}break}var y=n&&!i,E={levels:l,audioTracks:s,subtitleTracks:o,sessionData:e.sessionData,sessionKeys:e.sessionKeys,firstLevel:this._firstLevel,stats:e.stats,audio:n,video:i,altAudio:!y&&s.some((function(t){return!!t.url}))};this.hls.trigger(S.MANIFEST_PARSED,E),(this.hls.config.autoStartLoad||this.hls.forceStartLoad)&&this.hls.startLoad(this.hls.config.startPosition)}else Promise.resolve().then((function(){if(a.hls){e.levels.length&&a.warn("One or more CODECS in variant not supported: "+JSON.stringify(e.levels[0].attrs));var t=new Error("no level with compatible codecs found in manifest");a.hls.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.MANIFEST_INCOMPATIBLE_CODECS_ERROR,fatal:!0,url:e.url,error:t,reason:t.message})}}))},r.onError=function(t,e){!e.fatal&&e.context&&e.context.type===ke&&e.context.level===this.level&&this.checkRetry(e)},r.onFragBuffered=function(t,e){var r=e.frag;if(void 0!==r&&r.type===Ie){var i=r.elementaryStreams;if(!Object.keys(i).some((function(t){return!!i[t]})))return;var n=this._levels[r.level];null!=n&&n.loadError&&(this.log("Resetting level error count of "+n.loadError+" on frag buffered"),n.loadError=0)}},r.onLevelLoaded=function(t,e){var r,i,n=e.level,a=e.details,s=this._levels[n];if(!s)return this.warn("Invalid level index "+n),void(null!=(i=e.deliveryDirectives)&&i.skip&&(a.deltaUpdateFailed=!0));n===this.currentLevelIndex?(0===s.fragmentError&&(s.loadError=0),this.playlistLoaded(n,e,s.details)):null!=(r=e.deliveryDirectives)&&r.skip&&(a.deltaUpdateFailed=!0)},r.loadPlaylist=function(e){t.prototype.loadPlaylist.call(this);var r=this.currentLevelIndex,i=this.currentLevel;if(i&&this.shouldLoadPlaylist(i)){var n=i.uri;if(e)try{n=e.addDirectives(n)}catch(t){this.warn("Could not construct new URL with HLS Delivery Directives: "+t)}var a=i.attrs["PATHWAY-ID"];this.log("Loading level index "+r+(void 0!==(null==e?void 0:e.msn)?" at sn "+e.msn+" part "+e.part:"")+" with"+(a?" Pathway "+a:"")+" "+n),this.clearTimer(),this.hls.trigger(S.LEVEL_LOADING,{url:n,level:r,pathwayId:i.attrs["PATHWAY-ID"],id:0,deliveryDirectives:e||null})}},r.removeLevel=function(t){var e,r=this,i=this._levels.filter((function(e,i){return i!==t||(r.steering&&r.steering.removeLevel(e),e===r.currentLevel&&(r.currentLevel=null,r.currentLevelIndex=-1,e.details&&e.details.fragments.forEach((function(t){return t.level=-1}))),!1)}));ur(i),this._levels=i,this.currentLevelIndex>-1&&null!=(e=this.currentLevel)&&e.details&&(this.currentLevelIndex=this.currentLevel.details.fragments[0].level),this.hls.trigger(S.LEVELS_UPDATED,{levels:i})},r.onLevelsUpdated=function(t,e){var r=e.levels;this._levels=r},r.checkMaxAutoUpdated=function(){var t=this.hls,e=t.autoLevelCapping,r=t.maxAutoLevel,i=t.maxHdcpLevel;this._maxAutoLevel!==r&&(this._maxAutoLevel=r,this.hls.trigger(S.MAX_AUTO_LEVEL_UPDATED,{autoLevelCapping:e,levels:this.levels,maxAutoLevel:r,minAutoLevel:this.hls.minAutoLevel,maxHdcpLevel:i}))},s(e,[{key:"levels",get:function(){return 0===this._levels.length?null:this._levels}},{key:"level",get:function(){return this.currentLevelIndex},set:function(t){var e=this._levels;if(0!==e.length){if(t<0||t>=e.length){var r=new Error("invalid level idx"),i=t<0;if(this.hls.trigger(S.ERROR,{type:L.OTHER_ERROR,details:A.LEVEL_SWITCH_ERROR,level:t,fatal:i,error:r,reason:r.message}),i)return;t=Math.min(t,e.length-1)}var n=this.currentLevelIndex,a=this.currentLevel,s=a?a.attrs["PATHWAY-ID"]:void 0,o=e[t],l=o.attrs["PATHWAY-ID"];if(this.currentLevelIndex=t,this.currentLevel=o,n!==t||!o.details||!a||s!==l){this.log("Switching to level "+t+" ("+(o.height?o.height+"p ":"")+(o.videoRange?o.videoRange+" ":"")+(o.codecSet?o.codecSet+" ":"")+"@"+o.bitrate+")"+(l?" with Pathway "+l:"")+" from level "+n+(s?" with Pathway "+s:""));var u={level:t,attrs:o.attrs,details:o.details,bitrate:o.bitrate,averageBitrate:o.averageBitrate,maxBitrate:o.maxBitrate,realBitrate:o.realBitrate,width:o.width,height:o.height,codecSet:o.codecSet,audioCodec:o.audioCodec,videoCodec:o.videoCodec,audioGroups:o.audioGroups,subtitleGroups:o.subtitleGroups,loaded:o.loaded,loadError:o.loadError,fragmentError:o.fragmentError,name:o.name,id:o.id,uri:o.uri,url:o.url,urlId:0,audioGroupIds:o.audioGroupIds,textGroupIds:o.textGroupIds};this.hls.trigger(S.LEVEL_SWITCHING,u);var h=o.details;if(!h||h.live){var d=this.switchParams(o.uri,null==a?void 0:a.details);this.loadPlaylist(d)}}}}},{key:"manualLevel",get:function(){return this.manualLevelIndex},set:function(t){this.manualLevelIndex=t,void 0===this._startLevel&&(this._startLevel=t),-1!==t&&(this.level=t)}},{key:"firstLevel",get:function(){return this._firstLevel},set:function(t){this._firstLevel=t}},{key:"startLevel",get:function(){if(void 0===this._startLevel){var t=this.hls.config.startLevel;return void 0!==t?t:this.hls.firstAutoLevel}return this._startLevel},set:function(t){this._startLevel=t}},{key:"nextLoadLevel",get:function(){return-1!==this.manualLevelIndex?this.manualLevelIndex:this.hls.nextAutoLevel},set:function(t){this.level=t,-1===this.manualLevelIndex&&(this.hls.nextAutoLevel=t)}}]),e}(Dr);function Zs(t){var e={};t.forEach((function(t){var r=t.groupId||"";t.id=e[r]=e[r]||0,e[r]++}))}var to=function(){function t(t){this.config=void 0,this.keyUriToKeyInfo={},this.emeController=null,this.config=t}var e=t.prototype;return e.abort=function(t){for(var e in this.keyUriToKeyInfo){var r=this.keyUriToKeyInfo[e].loader;if(r){var i;if(t&&t!==(null==(i=r.context)?void 0:i.frag.type))return;r.abort()}}},e.detach=function(){for(var t in this.keyUriToKeyInfo){var e=this.keyUriToKeyInfo[t];(e.mediaKeySessionContext||e.decryptdata.isCommonEncryption)&&delete this.keyUriToKeyInfo[t]}},e.destroy=function(){for(var t in this.detach(),this.keyUriToKeyInfo){var e=this.keyUriToKeyInfo[t].loader;e&&e.destroy()}this.keyUriToKeyInfo={}},e.createKeyLoadError=function(t,e,r,i,n){return void 0===e&&(e=A.KEY_LOAD_ERROR),new si({type:L.NETWORK_ERROR,details:e,fatal:!1,frag:t,response:n,error:r,networkDetails:i})},e.loadClear=function(t,e){var r=this;if(this.emeController&&this.config.emeEnabled)for(var i=t.sn,n=t.cc,a=function(){var t=e[s];if(n<=t.cc&&("initSegment"===i||"initSegment"===t.sn||i<t.sn))return r.emeController.selectKeySystemFormat(t).then((function(e){t.setKeyFormat(e)})),1},s=0;s<e.length&&!a();s++);},e.load=function(t){var e=this;return!t.decryptdata&&t.encrypted&&this.emeController?this.emeController.selectKeySystemFormat(t).then((function(r){return e.loadInternal(t,r)})):this.loadInternal(t)},e.loadInternal=function(t,e){var r,i;e&&t.setKeyFormat(e);var n=t.decryptdata;if(!n){var a=new Error(e?"Expected frag.decryptdata to be defined after setting format "+e:"Missing decryption data on fragment in onKeyLoading");return Promise.reject(this.createKeyLoadError(t,A.KEY_LOAD_ERROR,a))}var s=n.uri;if(!s)return Promise.reject(this.createKeyLoadError(t,A.KEY_LOAD_ERROR,new Error('Invalid key URI: "'+s+'"')));var o,l=this.keyUriToKeyInfo[s];if(null!=(r=l)&&r.decryptdata.key)return n.key=l.decryptdata.key,Promise.resolve({frag:t,keyInfo:l});if(null!=(i=l)&&i.keyLoadPromise)switch(null==(o=l.mediaKeySessionContext)?void 0:o.keyStatus){case void 0:case"status-pending":case"usable":case"usable-in-future":return l.keyLoadPromise.then((function(e){return n.key=e.keyInfo.decryptdata.key,{frag:t,keyInfo:l}}))}switch(l=this.keyUriToKeyInfo[s]={decryptdata:n,keyLoadPromise:null,loader:null,mediaKeySessionContext:null},n.method){case"ISO-23001-7":case"SAMPLE-AES":case"SAMPLE-AES-CENC":case"SAMPLE-AES-CTR":return"identity"===n.keyFormat?this.loadKeyHTTP(l,t):this.loadKeyEME(l,t);case"AES-128":return this.loadKeyHTTP(l,t);default:return Promise.reject(this.createKeyLoadError(t,A.KEY_LOAD_ERROR,new Error('Key supplied with unsupported METHOD: "'+n.method+'"')))}},e.loadKeyEME=function(t,e){var r={frag:e,keyInfo:t};if(this.emeController&&this.config.emeEnabled){var i=this.emeController.loadKey(r);if(i)return(t.keyLoadPromise=i.then((function(e){return t.mediaKeySessionContext=e,r}))).catch((function(e){throw t.keyLoadPromise=null,e}))}return Promise.resolve(r)},e.loadKeyHTTP=function(t,e){var r=this,n=this.config,a=new(0,n.loader)(n);return e.keyLoader=t.loader=a,t.keyLoadPromise=new Promise((function(s,o){var l={keyInfo:t,frag:e,responseType:"arraybuffer",url:t.decryptdata.uri},u=n.keyLoadPolicy.default,h={loadPolicy:u,timeout:u.maxLoadTimeMs,maxRetry:0,retryDelay:0,maxRetryDelay:0},d={onSuccess:function(t,e,i,n){var a=i.frag,l=i.keyInfo,u=i.url;if(!a.decryptdata||l!==r.keyUriToKeyInfo[u])return o(r.createKeyLoadError(a,A.KEY_LOAD_ERROR,new Error("after key load, decryptdata unset or changed"),n));l.decryptdata.key=a.decryptdata.key=new Uint8Array(t.data),a.keyLoader=null,l.loader=null,s({frag:a,keyInfo:l})},onError:function(t,n,a,s){r.resetLoader(n),o(r.createKeyLoadError(e,A.KEY_LOAD_ERROR,new Error("HTTP Error "+t.code+" loading key "+t.text),a,i({url:l.url,data:void 0},t)))},onTimeout:function(t,i,n){r.resetLoader(i),o(r.createKeyLoadError(e,A.KEY_LOAD_TIMEOUT,new Error("key loading timed out"),n))},onAbort:function(t,i,n){r.resetLoader(i),o(r.createKeyLoadError(e,A.INTERNAL_ABORTED,new Error("key loading aborted"),n))}};a.load(l,h,d)}))},e.resetLoader=function(t){var e=t.frag,r=t.keyInfo,i=t.url,n=r.loader;e.keyLoader===n&&(e.keyLoader=null,r.loader=null),delete this.keyUriToKeyInfo[i],n&&n.destroy()},t}();function eo(){return self.SourceBuffer||self.WebKitSourceBuffer}function ro(){if(!te())return!1;var t=eo();return!t||t.prototype&&"function"==typeof t.prototype.appendBuffer&&"function"==typeof t.prototype.remove}var io=function(){function t(t,e,r,i){this.config=void 0,this.media=null,this.fragmentTracker=void 0,this.hls=void 0,this.nudgeRetry=0,this.stallReported=!1,this.stalled=null,this.moved=!1,this.seeking=!1,this.config=t,this.media=e,this.fragmentTracker=r,this.hls=i}var e=t.prototype;return e.destroy=function(){this.media=null,this.hls=this.fragmentTracker=null},e.poll=function(t,e){var r=this.config,i=this.media,n=this.stalled;if(null!==i){var a=i.currentTime,s=i.seeking,o=this.seeking&&!s,l=!this.seeking&&s;if(this.seeking=s,a===t)if(l||o)this.stalled=null;else if(i.paused&&!s||i.ended||0===i.playbackRate||!zr.getBuffered(i).length)this.nudgeRetry=0;else{var u=zr.bufferInfo(i,a,0),h=u.nextStart||0;if(s){var d=u.len>2,c=!h||e&&e.start<=a||h-a>2&&!this.fragmentTracker.getPartialFragment(a);if(d||c)return;this.moved=!1}if(!this.moved&&null!==this.stalled){var f;if(!(u.len>0||h))return;var g=Math.max(h,u.start||0)-a,v=this.hls.levels?this.hls.levels[this.hls.currentLevel]:null,m=(null==v||null==(f=v.details)?void 0:f.live)?2*v.details.targetduration:2,p=this.fragmentTracker.getPartialFragment(a);if(g>0&&(g<=m||p))return void(i.paused||this._trySkipBufferHole(p))}var y=self.performance.now();if(null!==n){var E=y-n;if(s||!(E>=250)||(this._reportStall(u),this.media)){var T=zr.bufferInfo(i,a,r.maxBufferHole);this._tryFixBufferStall(T,E)}}else this.stalled=y}else if(this.moved=!0,s||(this.nudgeRetry=0),null!==n){if(this.stallReported){var S=self.performance.now()-n;w.warn("playback not stuck anymore @"+a+", after "+Math.round(S)+"ms"),this.stallReported=!1}this.stalled=null}}},e._tryFixBufferStall=function(t,e){var r=this.config,i=this.fragmentTracker,n=this.media;if(null!==n){var a=n.currentTime,s=i.getPartialFragment(a);if(s&&(this._trySkipBufferHole(s)||!this.media))return;(t.len>r.maxBufferHole||t.nextStart&&t.nextStart-a<r.maxBufferHole)&&e>1e3*r.highBufferWatchdogPeriod&&(w.warn("Trying to nudge playhead over buffer-hole"),this.stalled=null,this._tryNudgeBuffer())}},e._reportStall=function(t){var e=this.hls,r=this.media;if(!this.stallReported&&r){this.stallReported=!0;var i=new Error("Playback stalling at @"+r.currentTime+" due to low buffer ("+JSON.stringify(t)+")");w.warn(i.message),e.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.BUFFER_STALLED_ERROR,fatal:!1,error:i,buffer:t.len})}},e._trySkipBufferHole=function(t){var e=this.config,r=this.hls,i=this.media;if(null===i)return 0;var n=i.currentTime,a=zr.bufferInfo(i,n,0),s=n<a.start?a.start:a.nextStart;if(s){var o=a.len<=e.maxBufferHole,l=a.len>0&&a.len<1&&i.readyState<3,u=s-n;if(u>0&&(o||l)){if(u>e.maxBufferHole){var h=this.fragmentTracker,d=!1;if(0===n){var c=h.getAppendedFrag(0,Ie);c&&s<c.end&&(d=!0)}if(!d){var f=t||h.getAppendedFrag(n,Ie);if(f){for(var g=!1,v=f.end;v<s;){var m=h.getPartialFragment(v);if(!m){g=!0;break}v+=m.duration}if(g)return 0}}}var p=Math.max(s+.05,n+.1);if(w.warn("skipping hole, adjusting currentTime from "+n+" to "+p),this.moved=!0,this.stalled=null,i.currentTime=p,t&&!t.gap){var y=new Error("fragment loaded with buffer holes, seeking from "+n+" to "+p);r.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.BUFFER_SEEK_OVER_HOLE,fatal:!1,error:y,reason:y.message,frag:t})}return p}}return 0},e._tryNudgeBuffer=function(){var t=this.config,e=this.hls,r=this.media,i=this.nudgeRetry;if(null!==r){var n=r.currentTime;if(this.nudgeRetry++,i<t.nudgeMaxRetry){var a=n+(i+1)*t.nudgeOffset,s=new Error("Nudging 'currentTime' from "+n+" to "+a);w.warn(s.message),r.currentTime=a,e.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.BUFFER_NUDGE_ON_STALL,error:s,fatal:!1})}else{var o=new Error("Playhead still not moving while enough data buffered @"+n+" after "+t.nudgeMaxRetry+" nudges");w.error(o.message),e.trigger(S.ERROR,{type:L.MEDIA_ERROR,details:A.BUFFER_STALLED_ERROR,error:o,fatal:!0})}}},t}(),no=function(t){function e(e,r,i){var n;return(n=t.call(this,e,r,i,"[stream-controller]",Ie)||this).audioCodecSwap=!1,n.gapController=null,n.level=-1,n._forceStartLoad=!1,n.altAudio=!1,n.audioOnly=!1,n.fragPlaying=null,n.onvplaying=null,n.onvseeked=null,n.fragLastKbps=0,n.couldBacktrack=!1,n.backtrackFragment=null,n.audioCodecSwitch=!1,n.videoBuffer=null,n._registerListeners(),n}l(e,t);var r=e.prototype;return r._registerListeners=function(){var t=this.hls;t.on(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.on(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.on(S.MANIFEST_LOADING,this.onManifestLoading,this),t.on(S.MANIFEST_PARSED,this.onManifestParsed,this),t.on(S.LEVEL_LOADING,this.onLevelLoading,this),t.on(S.LEVEL_LOADED,this.onLevelLoaded,this),t.on(S.FRAG_LOAD_EMERGENCY_ABORTED,this.onFragLoadEmergencyAborted,this),t.on(S.ERROR,this.onError,this),t.on(S.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),t.on(S.AUDIO_TRACK_SWITCHED,this.onAudioTrackSwitched,this),t.on(S.BUFFER_CREATED,this.onBufferCreated,this),t.on(S.BUFFER_FLUSHED,this.onBufferFlushed,this),t.on(S.LEVELS_UPDATED,this.onLevelsUpdated,this),t.on(S.FRAG_BUFFERED,this.onFragBuffered,this)},r._unregisterListeners=function(){var t=this.hls;t.off(S.MEDIA_ATTACHED,this.onMediaAttached,this),t.off(S.MEDIA_DETACHING,this.onMediaDetaching,this),t.off(S.MANIFEST_LOADING,this.onManifestLoading,this),t.off(S.MANIFEST_PARSED,this.onManifestParsed,this),t.off(S.LEVEL_LOADED,this.onLevelLoaded,this),t.off(S.FRAG_LOAD_EMERGENCY_ABORTED,this.onFragLoadEmergencyAborted,this),t.off(S.ERROR,this.onError,this),t.off(S.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),t.off(S.AUDIO_TRACK_SWITCHED,this.onAudioTrackSwitched,this),t.off(S.BUFFER_CREATED,this.onBufferCreated,this),t.off(S.BUFFER_FLUSHED,this.onBufferFlushed,this),t.off(S.LEVELS_UPDATED,this.onLevelsUpdated,this),t.off(S.FRAG_BUFFERED,this.onFragBuffered,this)},r.onHandlerDestroying=function(){this._unregisterListeners(),t.prototype.onHandlerDestroying.call(this)},r.startLoad=function(t){if(this.levels){var e=this.lastCurrentTime,r=this.hls;if(this.stopLoad(),this.setInterval(100),this.level=-1,!this.startFragRequested){var i=r.startLevel;-1===i&&(r.config.testBandwidth&&this.levels.length>1?(i=0,this.bitrateTest=!0):i=r.firstAutoLevel),this.level=r.nextLoadLevel=i,this.loadedmetadata=!1}e>0&&-1===t&&(this.log("Override startPosition with lastCurrentTime @"+e.toFixed(3)),t=e),this.state=fi,this.nextLoadPosition=this.startPosition=this.lastCurrentTime=t,this.tick()}else this._forceStartLoad=!0,this.state=ci},r.stopLoad=function(){this._forceStartLoad=!1,t.prototype.stopLoad.call(this)},r.doTick=function(){switch(this.state){case Ai:var t=this.levels,e=this.level,r=null==t?void 0:t[e],i=null==r?void 0:r.details;if(i&&(!i.live||this.levelLastLoaded===r)){if(this.waitForCdnTuneIn(i))break;this.state=fi;break}if(this.hls.nextLoadLevel!==this.level){this.state=fi;break}break;case mi:var n,a=self.performance.now(),s=this.retryDate;if(!s||a>=s||null!=(n=this.media)&&n.seeking){var o=this.levels,l=this.level,u=null==o?void 0:o[l];this.resetStartWhenNotLoaded(u||null),this.state=fi}}this.state===fi&&this.doTickIdle(),this.onTickEnd()},r.onTickEnd=function(){t.prototype.onTickEnd.call(this),this.checkBuffer(),this.checkFragmentChanged()},r.doTickIdle=function(){var t=this.hls,e=this.levelLastLoaded,r=this.levels,i=this.media,n=t.config,a=t.nextLoadLevel;if(null!==e&&(i||!this.startFragRequested&&n.startFragPrefetch)&&(!this.altAudio||!this.audioOnly)&&null!=r&&r[a]){var s=r[a],o=this.getMainFwdBufferInfo();if(null!==o){var l=this.getLevelDetails();if(l&&this._streamEnded(o,l)){var u={};return this.altAudio&&(u.type="video"),this.hls.trigger(S.BUFFER_EOS,u),void(this.state=Ti)}t.loadLevel!==a&&-1===t.manualLevel&&this.log("Adapting to level "+a+" from level "+this.level),this.level=t.nextLoadLevel=a;var h=s.details;if(!h||this.state===Ai||h.live&&this.levelLastLoaded!==s)return this.level=a,void(this.state=Ai);var d=o.len,c=this.getMaxBufferLength(s.maxBitrate);if(!(d>=c)){this.backtrackFragment&&this.backtrackFragment.start>o.end&&(this.backtrackFragment=null);var f=this.backtrackFragment?this.backtrackFragment.start:o.end,g=this.getNextFragment(f,h);if(this.couldBacktrack&&!this.fragPrevious&&g&&"initSegment"!==g.sn&&this.fragmentTracker.getState(g)!==Yr){var v,m=(null!=(v=this.backtrackFragment)?v:g).sn-h.startSN,p=h.fragments[m-1];p&&g.cc===p.cc&&(g=p,this.fragmentTracker.removeFragment(p))}else this.backtrackFragment&&o.len&&(this.backtrackFragment=null);if(g&&this.isLoopLoading(g,f)){if(!g.gap){var y=this.audioOnly&&!this.altAudio?O:N,E=(y===N?this.videoBuffer:this.mediaBuffer)||this.media;E&&this.afterBufferFlushed(E,y,Ie)}g=this.getNextFragmentLoopLoading(g,h,o,Ie,c)}g&&(!g.initSegment||g.initSegment.data||this.bitrateTest||(g=g.initSegment),this.loadFragment(g,s,f))}}}},r.loadFragment=function(e,r,i){var n=this.fragmentTracker.getState(e);this.fragCurrent=e,n===Kr||n===Vr?"initSegment"===e.sn?this._loadInitSegment(e,r):this.bitrateTest?(this.log("Fragment "+e.sn+" of level "+e.level+" is being downloaded to test bitrate and will not be buffered"),this._loadBitrateTestFrag(e,r)):(this.startFragRequested=!0,t.prototype.loadFragment.call(this,e,r,i)):this.clearTrackerIfNeeded(e)},r.getBufferedFrag=function(t){return this.fragmentTracker.getBufferedFrag(t,Ie)},r.followingBufferedFrag=function(t){return t?this.getBufferedFrag(t.end+.5):null},r.immediateLevelSwitch=function(){this.abortCurrentFrag(),this.flushMainBuffer(0,Number.POSITIVE_INFINITY)},r.nextLevelSwitch=function(){var t=this.levels,e=this.media;if(null!=e&&e.readyState){var r,i=this.getAppendedFrag(e.currentTime);i&&i.start>1&&this.flushMainBuffer(0,i.start-1);var n=this.getLevelDetails();if(null!=n&&n.live){var a=this.getMainFwdBufferInfo();if(!a||a.len<2*n.targetduration)return}if(!e.paused&&t){var s=t[this.hls.nextLoadLevel],o=this.fragLastKbps;r=o&&this.fragCurrent?this.fragCurrent.duration*s.maxBitrate/(1e3*o)+1:0}else r=0;var l=this.getBufferedFrag(e.currentTime+r);if(l){var u=this.followingBufferedFrag(l);if(u){this.abortCurrentFrag();var h=u.maxStartPTS?u.maxStartPTS:u.start,d=u.duration,c=Math.max(l.end,h+Math.min(Math.max(d-this.config.maxFragLookUpTolerance,d*(this.couldBacktrack?.5:.125)),d*(this.couldBacktrack?.75:.25)));this.flushMainBuffer(c,Number.POSITIVE_INFINITY)}}}},r.abortCurrentFrag=function(){var t=this.fragCurrent;switch(this.fragCurrent=null,this.backtrackFragment=null,t&&(t.abortRequests(),this.fragmentTracker.removeFragment(t)),this.state){case gi:case vi:case mi:case yi:case Ei:this.state=fi}this.nextLoadPosition=this.getLoadPosition()},r.flushMainBuffer=function(e,r){t.prototype.flushMainBuffer.call(this,e,r,this.altAudio?"video":null)},r.onMediaAttached=function(e,r){t.prototype.onMediaAttached.call(this,e,r);var i=r.media;this.onvplaying=this.onMediaPlaying.bind(this),this.onvseeked=this.onMediaSeeked.bind(this),i.addEventListener("playing",this.onvplaying),i.addEventListener("seeked",this.onvseeked),this.gapController=new io(this.config,i,this.fragmentTracker,this.hls)},r.onMediaDetaching=function(){var e=this.media;e&&this.onvplaying&&this.onvseeked&&(e.removeEventListener("playing",this.onvplaying),e.removeEventListener("seeked",this.onvseeked),this.onvplaying=this.onvseeked=null,this.videoBuffer=null),this.fragPlaying=null,this.gapController&&(this.gapController.destroy(),this.gapController=null),t.prototype.onMediaDetaching.call(this)},r.onMediaPlaying=function(){this.tick()},r.onMediaSeeked=function(){var t=this.media,e=t?t.currentTime:null;y(e)&&this.log("Media seeked to "+e.toFixed(3));var r=this.getMainFwdBufferInfo();null!==r&&0!==r.len?this.tick():this.warn('Main forward buffer length on "seeked" event '+(r?r.len:"empty")+")")},r.onManifestLoading=function(){this.log("Trigger BUFFER_RESET"),this.hls.trigger(S.BUFFER_RESET,void 0),this.fragmentTracker.removeAllFragments(),this.couldBacktrack=!1,this.startPosition=this.lastCurrentTime=this.fragLastKbps=0,this.levels=this.fragPlaying=this.backtrackFragment=this.levelLastLoaded=null,this.altAudio=this.audioOnly=this.startFragRequested=!1},r.onManifestParsed=function(t,e){var r,i,n=!1,a=!1;e.levels.forEach((function(t){var e=t.audioCodec;e&&(n=n||-1!==e.indexOf("mp4a.40.2"),a=a||-1!==e.indexOf("mp4a.40.5"))})),this.audioCodecSwitch=n&&a&&!("function"==typeof(null==(i=eo())||null==(r=i.prototype)?void 0:r.changeType)),this.audioCodecSwitch&&this.log("Both AAC/HE-AAC audio found in levels; declaring level codec as HE-AAC"),this.levels=e.levels,this.startFragRequested=!1},r.onLevelLoading=function(t,e){var r=this.levels;if(r&&this.state===fi){var i=r[e.level];(!i.details||i.details.live&&this.levelLastLoaded!==i||this.waitForCdnTuneIn(i.details))&&(this.state=Ai)}},r.onLevelLoaded=function(t,e){var r,i=this.levels,n=e.level,a=e.details,s=a.totalduration;if(i){this.log("Level "+n+" loaded ["+a.startSN+","+a.endSN+"]"+(a.lastPartSn?"[part-"+a.lastPartSn+"-"+a.lastPartIndex+"]":"")+", cc ["+a.startCC+", "+a.endCC+"] duration:"+s);var o=i[n],l=this.fragCurrent;!l||this.state!==vi&&this.state!==mi||l.level!==e.level&&l.loader&&this.abortCurrentFrag();var u=0;if(a.live||null!=(r=o.details)&&r.live){var h;if(this.checkLiveUpdate(a),a.deltaUpdateFailed)return;u=this.alignPlaylists(a,o.details,null==(h=this.levelLastLoaded)?void 0:h.details)}if(o.details=a,this.levelLastLoaded=o,this.hls.trigger(S.LEVEL_UPDATED,{details:a,level:n}),this.state===Ai){if(this.waitForCdnTuneIn(a))return;this.state=fi}this.startFragRequested?a.live&&this.synchronizeToLiveEdge(a):this.setStartPosition(a,u),this.tick()}else this.warn("Levels were reset while loading level "+n)},r._handleFragmentLoadProgress=function(t){var e,r=t.frag,i=t.part,n=t.payload,a=this.levels;if(a){var s=a[r.level],o=s.details;if(!o)return this.warn("Dropping fragment "+r.sn+" of level "+r.level+" after level details were reset"),void this.fragmentTracker.removeFragment(r);var l=s.videoCodec,u=o.PTSKnown||!o.live,h=null==(e=r.initSegment)?void 0:e.data,d=this._getAudioCodec(s),c=this.transmuxer=this.transmuxer||new Bn(this.hls,Ie,this._handleTransmuxComplete.bind(this),this._handleTransmuxerFlush.bind(this)),f=i?i.index:-1,g=-1!==f,v=new Qr(r.level,r.sn,r.stats.chunkCount,n.byteLength,f,g),m=this.initPTS[r.cc];c.push(n,h,d,l,r,i,o.totalduration,u,v,m)}else this.warn("Levels were reset while fragment load was in progress. Fragment "+r.sn+" of level "+r.level+" will not be buffered")},r.onAudioTrackSwitching=function(t,e){var r=this.altAudio;if(!e.url){if(this.mediaBuffer!==this.media){this.log("Switching on main audio, use media.buffered to schedule main fragment loading"),this.mediaBuffer=this.media;var i=this.fragCurrent;i&&(this.log("Switching to main audio track, cancel main fragment load"),i.abortRequests(),this.fragmentTracker.removeFragment(i)),this.resetTransmuxer(),this.resetLoadingState()}else this.audioOnly&&this.resetTransmuxer();var n=this.hls;r&&(n.trigger(S.BUFFER_FLUSHING,{startOffset:0,endOffset:Number.POSITIVE_INFINITY,type:null}),this.fragmentTracker.removeAllFragments()),n.trigger(S.AUDIO_TRACK_SWITCHED,e)}},r.onAudioTrackSwitched=function(t,e){var r=e.id,i=!!this.hls.audioTracks[r].url;if(i){var n=this.videoBuffer;n&&this.mediaBuffer!==n&&(this.log("Switching on alternate audio, use video.buffered to schedule main fragment loading"),this.mediaBuffer=n)}this.altAudio=i,this.tick()},r.onBufferCreated=function(t,e){var r,i,n=e.tracks,a=!1;for(var s in n){var o=n[s];if("main"===o.id){if(i=s,r=o,"video"===s){var l=n[s];l&&(this.videoBuffer=l.buffer)}}else a=!0}a&&r?(this.log("Alternate track found, use "+i+".buffered to schedule main fragment loading"),this.mediaBuffer=r.buffer):this.mediaBuffer=this.media},r.onFragBuffered=function(t,e){var r=e.frag,i=e.part;if(!r||r.type===Ie){if(this.fragContextChanged(r))return this.warn("Fragment "+r.sn+(i?" p: "+i.index:"")+" of level "+r.level+" finished buffering, but was aborted. state: "+this.state),void(this.state===Ei&&(this.state=fi));var n=i?i.stats:r.stats;this.fragLastKbps=Math.round(8*n.total/(n.buffering.end-n.loading.first)),"initSegment"!==r.sn&&(this.fragPrevious=r),this.fragBufferedComplete(r,i)}},r.onError=function(t,e){var r;if(e.fatal)this.state=Si;else switch(e.details){case A.FRAG_GAP:case A.FRAG_PARSING_ERROR:case A.FRAG_DECRYPT_ERROR:case A.FRAG_LOAD_ERROR:case A.FRAG_LOAD_TIMEOUT:case A.KEY_LOAD_ERROR:case A.KEY_LOAD_TIMEOUT:this.onFragmentOrKeyLoadError(Ie,e);break;case A.LEVEL_LOAD_ERROR:case A.LEVEL_LOAD_TIMEOUT:case A.LEVEL_PARSING_ERROR:e.levelRetry||this.state!==Ai||(null==(r=e.context)?void 0:r.type)!==ke||(this.state=fi);break;case A.BUFFER_APPEND_ERROR:case A.BUFFER_FULL_ERROR:if(!e.parent||"main"!==e.parent)return;if(e.details===A.BUFFER_APPEND_ERROR)return void this.resetLoadingState();this.reduceLengthAndFlushBuffer(e)&&this.flushMainBuffer(0,Number.POSITIVE_INFINITY);break;case A.INTERNAL_EXCEPTION:this.recoverWorkerError(e)}},r.checkBuffer=function(){var t=this.media,e=this.gapController;if(t&&e&&t.readyState){if(this.loadedmetadata||!zr.getBuffered(t).length){var r=this.state!==fi?this.fragCurrent:null;e.poll(this.lastCurrentTime,r)}this.lastCurrentTime=t.currentTime}},r.onFragLoadEmergencyAborted=function(){this.state=fi,this.loadedmetadata||(this.startFragRequested=!1,this.nextLoadPosition=this.startPosition),this.tickImmediate()},r.onBufferFlushed=function(t,e){var r=e.type;if(r!==O||this.audioOnly&&!this.altAudio){var i=(r===N?this.videoBuffer:this.mediaBuffer)||this.media;this.afterBufferFlushed(i,r,Ie),this.tick()}},r.onLevelsUpdated=function(t,e){this.level>-1&&this.fragCurrent&&(this.level=this.fragCurrent.level),this.levels=e.levels},r.swapAudioCodec=function(){this.audioCodecSwap=!this.audioCodecSwap},r.seekToStartPos=function(){var t=this.media;if(t){var e=t.currentTime,r=this.startPosition;if(r>=0&&e<r){if(t.seeking)return void this.log("could not seek to "+r+", already seeking at "+e);var i=zr.getBuffered(t),n=(i.length?i.start(0):0)-r;n>0&&(n<this.config.maxBufferHole||n<this.config.maxFragLookUpTolerance)&&(this.log("adjusting start position by "+n+" to match buffer start"),r+=n,this.startPosition=r),this.log("seek to target start position "+r+" from current time "+e),t.currentTime=r}}},r._getAudioCodec=function(t){var e=this.config.defaultAudioCodec||t.audioCodec;return this.audioCodecSwap&&e&&(this.log("Swapping audio codec"),e=-1!==e.indexOf("mp4a.40.5")?"mp4a.40.2":"mp4a.40.5"),e},r._loadBitrateTestFrag=function(t,e){var r=this;t.bitrateTest=!0,this._doFragLoad(t,e).then((function(i){var n=r.hls;if(i&&!r.fragContextChanged(t)){e.fragmentError=0,r.state=fi,r.startFragRequested=!1,r.bitrateTest=!1;var a=t.stats;a.parsing.start=a.parsing.end=a.buffering.start=a.buffering.end=self.performance.now(),n.trigger(S.FRAG_LOADED,i),t.bitrateTest=!1}}))},r._handleTransmuxComplete=function(t){var e,r="main",i=this.hls,n=t.remuxResult,a=t.chunkMeta,s=this.getCurrentContext(a);if(s){var o=s.frag,l=s.part,u=s.level,h=n.video,d=n.text,c=n.id3,f=n.initSegment,g=u.details,v=this.altAudio?void 0:n.audio;if(this.fragContextChanged(o))this.fragmentTracker.removeFragment(o);else{if(this.state=yi,f){if(null!=f&&f.tracks){var m=o.initSegment||o;this._bufferInitSegment(u,f.tracks,m,a),i.trigger(S.FRAG_PARSING_INIT_SEGMENT,{frag:m,id:r,tracks:f.tracks})}var p=f.initPTS,E=f.timescale;y(p)&&(this.initPTS[o.cc]={baseTime:p,timescale:E},i.trigger(S.INIT_PTS_FOUND,{frag:o,id:r,initPTS:p,timescale:E}))}if(h&&g&&"initSegment"!==o.sn){var T=g.fragments[o.sn-1-g.startSN],L=o.sn===g.startSN,A=!T||o.cc>T.cc;if(!1!==n.independent){var R=h.startPTS,k=h.endPTS,b=h.startDTS,D=h.endDTS;if(l)l.elementaryStreams[h.type]={startPTS:R,endPTS:k,startDTS:b,endDTS:D};else if(h.firstKeyFrame&&h.independent&&1===a.id&&!A&&(this.couldBacktrack=!0),h.dropped&&h.independent){var I=this.getMainFwdBufferInfo(),w=(I?I.end:this.getLoadPosition())+this.config.maxBufferHole,C=h.firstKeyFramePTS?h.firstKeyFramePTS:R;if(!L&&w<C-this.config.maxBufferHole&&!A)return void this.backtrack(o);A&&(o.gap=!0),o.setElementaryStreamInfo(h.type,o.start,k,o.start,D,!0)}else L&&R>2&&(o.gap=!0);o.setElementaryStreamInfo(h.type,R,k,b,D),this.backtrackFragment&&(this.backtrackFragment=o),this.bufferFragmentData(h,o,l,a,L||A)}else{if(!L&&!A)return void this.backtrack(o);o.gap=!0}}if(v){var _=v.startPTS,x=v.endPTS,P=v.startDTS,F=v.endDTS;l&&(l.elementaryStreams[O]={startPTS:_,endPTS:x,startDTS:P,endDTS:F}),o.setElementaryStreamInfo(O,_,x,P,F),this.bufferFragmentData(v,o,l,a)}if(g&&null!=c&&null!=(e=c.samples)&&e.length){var M={id:r,frag:o,details:g,samples:c.samples};i.trigger(S.FRAG_PARSING_METADATA,M)}if(g&&d){var N={id:r,frag:o,details:g,samples:d.samples};i.trigger(S.FRAG_PARSING_USERDATA,N)}}}else this.resetWhenMissingContext(a)},r._bufferInitSegment=function(t,e,r,i){var n=this;if(this.state===yi){this.audioOnly=!!e.audio&&!e.video,this.altAudio&&!this.audioOnly&&delete e.audio;var a=e.audio,s=e.video,o=e.audiovideo;if(a){var l=t.audioCodec,u=navigator.userAgent.toLowerCase();this.audioCodecSwitch&&(l&&(l=-1!==l.indexOf("mp4a.40.5")?"mp4a.40.2":"mp4a.40.5"),1!==a.metadata.channelCount&&-1===u.indexOf("firefox")&&(l="mp4a.40.5")),l&&-1!==l.indexOf("mp4a.40.5")&&-1!==u.indexOf("android")&&"audio/mpeg"!==a.container&&(l="mp4a.40.2",this.log("Android: force audio codec to "+l)),t.audioCodec&&t.audioCodec!==l&&this.log('Swapping manifest audio codec "'+t.audioCodec+'" for "'+l+'"'),a.levelCodec=l,a.id="main",this.log("Init audio buffer, container:"+a.container+", codecs[selected/level/parsed]=["+(l||"")+"/"+(t.audioCodec||"")+"/"+a.codec+"]")}s&&(s.levelCodec=t.videoCodec,s.id="main",this.log("Init video buffer, container:"+s.container+", codecs[level/parsed]=["+(t.videoCodec||"")+"/"+s.codec+"]")),o&&this.log("Init audiovideo buffer, container:"+o.container+", codecs[level/parsed]=["+t.codecs+"/"+o.codec+"]"),this.hls.trigger(S.BUFFER_CODECS,e),Object.keys(e).forEach((function(t){var a=e[t].initSegment;null!=a&&a.byteLength&&n.hls.trigger(S.BUFFER_APPENDING,{type:t,data:a,frag:r,part:null,chunkMeta:i,parent:r.type})})),this.tickImmediate()}},r.getMainFwdBufferInfo=function(){return this.getFwdBufferInfo(this.mediaBuffer?this.mediaBuffer:this.media,Ie)},r.backtrack=function(t){this.couldBacktrack=!0,this.backtrackFragment=t,this.resetTransmuxer(),this.flushBufferGap(t),this.fragmentTracker.removeFragment(t),this.fragPrevious=null,this.nextLoadPosition=t.start,this.state=fi},r.checkFragmentChanged=function(){var t=this.media,e=null;if(t&&t.readyState>1&&!1===t.seeking){var r=t.currentTime;if(zr.isBuffered(t,r)?e=this.getAppendedFrag(r):zr.isBuffered(t,r+.1)&&(e=this.getAppendedFrag(r+.1)),e){this.backtrackFragment=null;var i=this.fragPlaying,n=e.level;i&&e.sn===i.sn&&i.level===n||(this.fragPlaying=e,this.hls.trigger(S.FRAG_CHANGED,{frag:e}),i&&i.level===n||this.hls.trigger(S.LEVEL_SWITCHED,{level:n}))}}},s(e,[{key:"nextLevel",get:function(){var t=this.nextBufferedFrag;return t?t.level:-1}},{key:"currentFrag",get:function(){var t=this.media;return t?this.fragPlaying||this.getAppendedFrag(t.currentTime):null}},{key:"currentProgramDateTime",get:function(){var t=this.media;if(t){var e=t.currentTime,r=this.currentFrag;if(r&&y(e)&&y(r.programDateTime)){var i=r.programDateTime+1e3*(e-r.start);return new Date(i)}}return null}},{key:"currentLevel",get:function(){var t=this.currentFrag;return t?t.level:-1}},{key:"nextBufferedFrag",get:function(){var t=this.currentFrag;return t?this.followingBufferedFrag(t):null}},{key:"forceStartLoad",get:function(){return this._forceStartLoad}}]),e}(Ri),ao=function(){function t(e){void 0===e&&(e={}),this.config=void 0,this.userConfig=void 0,this.coreComponents=void 0,this.networkControllers=void 0,this.started=!1,this._emitter=new Mn,this._autoLevelCapping=-1,this._maxHdcpLevel=null,this.abrController=void 0,this.bufferController=void 0,this.capLevelController=void 0,this.latencyController=void 0,this.levelController=void 0,this.streamController=void 0,this.audioTrackController=void 0,this.subtitleTrackController=void 0,this.emeController=void 0,this.cmcdController=void 0,this._media=null,this.url=null,this.triggeringException=void 0,I(e.debug||!1,"Hls instance");var r=this.config=function(t,e){if((e.liveSyncDurationCount||e.liveMaxLatencyDurationCount)&&(e.liveSyncDuration||e.liveMaxLatencyDuration))throw new Error("Illegal hls.js config: don't mix up liveSyncDurationCount/liveMaxLatencyDurationCount and liveSyncDuration/liveMaxLatencyDuration");if(void 0!==e.liveMaxLatencyDurationCount&&(void 0===e.liveSyncDurationCount||e.liveMaxLatencyDurationCount<=e.liveSyncDurationCount))throw new Error('Illegal hls.js config: "liveMaxLatencyDurationCount" must be greater than "liveSyncDurationCount"');if(void 0!==e.liveMaxLatencyDuration&&(void 0===e.liveSyncDuration||e.liveMaxLatencyDuration<=e.liveSyncDuration))throw new Error('Illegal hls.js config: "liveMaxLatencyDuration" must be greater than "liveSyncDuration"');var r=Qs(t),n=["TimeOut","MaxRetry","RetryDelay","MaxRetryTimeout"];return["manifest","level","frag"].forEach((function(t){var i=("level"===t?"playlist":t)+"LoadPolicy",a=void 0===e[i],s=[];n.forEach((function(n){var o=t+"Loading"+n,l=e[o];if(void 0!==l&&a){s.push(o);var u=r[i].default;switch(e[i]={default:u},n){case"TimeOut":u.maxLoadTimeMs=l,u.maxTimeToFirstByteMs=l;break;case"MaxRetry":u.errorRetry.maxNumRetry=l,u.timeoutRetry.maxNumRetry=l;break;case"RetryDelay":u.errorRetry.retryDelayMs=l,u.timeoutRetry.retryDelayMs=l;break;case"MaxRetryTimeout":u.errorRetry.maxRetryDelayMs=l,u.timeoutRetry.maxRetryDelayMs=l}}})),s.length&&w.warn('hls.js config: "'+s.join('", "')+'" setting(s) are deprecated, use "'+i+'": '+JSON.stringify(e[i]))})),i(i({},r),e)}(t.DefaultConfig,e);this.userConfig=e,r.progressive&&Js(r);var n=r.abrController,a=r.bufferController,s=r.capLevelController,o=r.errorController,l=r.fpsController,u=new o(this),h=this.abrController=new n(this),d=this.bufferController=new a(this),c=this.capLevelController=new s(this),f=new l(this),g=new Pe(this),v=new je(this),m=r.contentSteeringController,p=m?new m(this):null,y=this.levelController=new $s(this,p),E=new Wr(this),T=new to(this.config),L=this.streamController=new no(this,E,T);c.setStreamController(L),f.setStreamController(L);var A=[g,y,L];p&&A.splice(1,0,p),this.networkControllers=A;var R=[h,d,c,f,v,E];this.audioTrackController=this.createController(r.audioTrackController,A);var k=r.audioStreamController;k&&A.push(new k(this,E,T)),this.subtitleTrackController=this.createController(r.subtitleTrackController,A);var b=r.subtitleStreamController;b&&A.push(new b(this,E,T)),this.createController(r.timelineController,R),T.emeController=this.emeController=this.createController(r.emeController,R),this.cmcdController=this.createController(r.cmcdController,R),this.latencyController=this.createController(qe,R),this.coreComponents=R,A.push(u);var D=u.onErrorOut;"function"==typeof D&&this.on(S.ERROR,D,u)}t.isMSESupported=function(){return ro()},t.isSupported=function(){return function(){if(!ro())return!1;var t=te();return"function"==typeof(null==t?void 0:t.isTypeSupported)&&(["avc1.42E01E,mp4a.40.2","av01.0.01M.08","vp09.00.50.08"].some((function(e){return t.isTypeSupported(ne(e,"video"))}))||["mp4a.40.2","fLaC"].some((function(e){return t.isTypeSupported(ne(e,"audio"))})))}()},t.getMediaSource=function(){return te()};var e=t.prototype;return e.createController=function(t,e){if(t){var r=new t(this);return e&&e.push(r),r}return null},e.on=function(t,e,r){void 0===r&&(r=this),this._emitter.on(t,e,r)},e.once=function(t,e,r){void 0===r&&(r=this),this._emitter.once(t,e,r)},e.removeAllListeners=function(t){this._emitter.removeAllListeners(t)},e.off=function(t,e,r,i){void 0===r&&(r=this),this._emitter.off(t,e,r,i)},e.listeners=function(t){return this._emitter.listeners(t)},e.emit=function(t,e,r){return this._emitter.emit(t,e,r)},e.trigger=function(t,e){if(this.config.debug)return this.emit(t,t,e);try{return this.emit(t,t,e)}catch(e){if(w.error("An internal error happened while handling event "+t+'. Error message: "'+e.message+'". Here is a stacktrace:',e),!this.triggeringException){this.triggeringException=!0;var r=t===S.ERROR;this.trigger(S.ERROR,{type:L.OTHER_ERROR,details:A.INTERNAL_EXCEPTION,fatal:r,event:t,error:e}),this.triggeringException=!1}}return!1},e.listenerCount=function(t){return this._emitter.listenerCount(t)},e.destroy=function(){w.log("destroy"),this.trigger(S.DESTROYING,void 0),this.detachMedia(),this.removeAllListeners(),this._autoLevelCapping=-1,this.url=null,this.networkControllers.forEach((function(t){return t.destroy()})),this.networkControllers.length=0,this.coreComponents.forEach((function(t){return t.destroy()})),this.coreComponents.length=0;var t=this.config;t.xhrSetup=t.fetchSetup=void 0,this.userConfig=null},e.attachMedia=function(t){w.log("attachMedia"),this._media=t,this.trigger(S.MEDIA_ATTACHING,{media:t})},e.detachMedia=function(){w.log("detachMedia"),this.trigger(S.MEDIA_DETACHING,void 0),this._media=null},e.loadSource=function(t){this.stopLoad();var e=this.media,r=this.url,i=this.url=p.buildAbsoluteURL(self.location.href,t,{alwaysNormalize:!0});this._autoLevelCapping=-1,this._maxHdcpLevel=null,w.log("loadSource:"+i),e&&r&&(r!==i||this.bufferController.hasSourceTypes())&&(this.detachMedia(),this.attachMedia(e)),this.trigger(S.MANIFEST_LOADING,{url:t})},e.startLoad=function(t){void 0===t&&(t=-1),w.log("startLoad("+t+")"),this.started=!0,this.networkControllers.forEach((function(e){e.startLoad(t)}))},e.stopLoad=function(){w.log("stopLoad"),this.started=!1,this.networkControllers.forEach((function(t){t.stopLoad()}))},e.resumeBuffering=function(){this.started&&this.networkControllers.forEach((function(t){"fragmentLoader"in t&&t.startLoad(-1)}))},e.pauseBuffering=function(){this.networkControllers.forEach((function(t){"fragmentLoader"in t&&t.stopLoad()}))},e.swapAudioCodec=function(){w.log("swapAudioCodec"),this.streamController.swapAudioCodec()},e.recoverMediaError=function(){w.log("recoverMediaError");var t=this._media;this.detachMedia(),t&&this.attachMedia(t)},e.removeLevel=function(t){this.levelController.removeLevel(t)},e.setAudioOption=function(t){var e;return null==(e=this.audioTrackController)?void 0:e.setAudioOption(t)},e.setSubtitleOption=function(t){var e;return null==(e=this.subtitleTrackController)||e.setSubtitleOption(t),null},s(t,[{key:"levels",get:function(){var t=this.levelController.levels;return t||[]}},{key:"currentLevel",get:function(){return this.streamController.currentLevel},set:function(t){w.log("set currentLevel:"+t),this.levelController.manualLevel=t,this.streamController.immediateLevelSwitch()}},{key:"nextLevel",get:function(){return this.streamController.nextLevel},set:function(t){w.log("set nextLevel:"+t),this.levelController.manualLevel=t,this.streamController.nextLevelSwitch()}},{key:"loadLevel",get:function(){return this.levelController.level},set:function(t){w.log("set loadLevel:"+t),this.levelController.manualLevel=t}},{key:"nextLoadLevel",get:function(){return this.levelController.nextLoadLevel},set:function(t){this.levelController.nextLoadLevel=t}},{key:"firstLevel",get:function(){return Math.max(this.levelController.firstLevel,this.minAutoLevel)},set:function(t){w.log("set firstLevel:"+t),this.levelController.firstLevel=t}},{key:"startLevel",get:function(){var t=this.levelController.startLevel;return-1===t&&this.abrController.forcedAutoLevel>-1?this.abrController.forcedAutoLevel:t},set:function(t){w.log("set startLevel:"+t),-1!==t&&(t=Math.max(t,this.minAutoLevel)),this.levelController.startLevel=t}},{key:"capLevelToPlayerSize",get:function(){return this.config.capLevelToPlayerSize},set:function(t){var e=!!t;e!==this.config.capLevelToPlayerSize&&(e?this.capLevelController.startCapping():(this.capLevelController.stopCapping(),this.autoLevelCapping=-1,this.streamController.nextLevelSwitch()),this.config.capLevelToPlayerSize=e)}},{key:"autoLevelCapping",get:function(){return this._autoLevelCapping},set:function(t){this._autoLevelCapping!==t&&(w.log("set autoLevelCapping:"+t),this._autoLevelCapping=t,this.levelController.checkMaxAutoUpdated())}},{key:"bandwidthEstimate",get:function(){var t=this.abrController.bwEstimator;return t?t.getEstimate():NaN},set:function(t){this.abrController.resetEstimator(t)}},{key:"ttfbEstimate",get:function(){var t=this.abrController.bwEstimator;return t?t.getEstimateTTFB():NaN}},{key:"maxHdcpLevel",get:function(){return this._maxHdcpLevel},set:function(t){(function(t){return Xe.indexOf(t)>-1})(t)&&this._maxHdcpLevel!==t&&(this._maxHdcpLevel=t,this.levelController.checkMaxAutoUpdated())}},{key:"autoLevelEnabled",get:function(){return-1===this.levelController.manualLevel}},{key:"manualLevel",get:function(){return this.levelController.manualLevel}},{key:"minAutoLevel",get:function(){var t=this.levels,e=this.config.minAutoBitrate;if(!t)return 0;for(var r=t.length,i=0;i<r;i++)if(t[i].maxBitrate>=e)return i;return 0}},{key:"maxAutoLevel",get:function(){var t,e=this.levels,r=this.autoLevelCapping,i=this.maxHdcpLevel;if(t=-1===r&&null!=e&&e.length?e.length-1:r,i)for(var n=t;n--;){var a=e[n].attrs["HDCP-LEVEL"];if(a&&a<=i)return n}return t}},{key:"firstAutoLevel",get:function(){return this.abrController.firstAutoLevel}},{key:"nextAutoLevel",get:function(){return this.abrController.nextAutoLevel},set:function(t){this.abrController.nextAutoLevel=t}},{key:"playingDate",get:function(){return this.streamController.currentProgramDateTime}},{key:"mainForwardBufferInfo",get:function(){return this.streamController.getMainFwdBufferInfo()}},{key:"allAudioTracks",get:function(){var t=this.audioTrackController;return t?t.allAudioTracks:[]}},{key:"audioTracks",get:function(){var t=this.audioTrackController;return t?t.audioTracks:[]}},{key:"audioTrack",get:function(){var t=this.audioTrackController;return t?t.audioTrack:-1},set:function(t){var e=this.audioTrackController;e&&(e.audioTrack=t)}},{key:"allSubtitleTracks",get:function(){var t=this.subtitleTrackController;return t?t.allSubtitleTracks:[]}},{key:"subtitleTracks",get:function(){var t=this.subtitleTrackController;return t?t.subtitleTracks:[]}},{key:"subtitleTrack",get:function(){var t=this.subtitleTrackController;return t?t.subtitleTrack:-1},set:function(t){var e=this.subtitleTrackController;e&&(e.subtitleTrack=t)}},{key:"media",get:function(){return this._media}},{key:"subtitleDisplay",get:function(){var t=this.subtitleTrackController;return!!t&&t.subtitleDisplay},set:function(t){var e=this.subtitleTrackController;e&&(e.subtitleDisplay=t)}},{key:"lowLatencyMode",get:function(){return this.config.lowLatencyMode},set:function(t){this.config.lowLatencyMode=t}},{key:"liveSyncPosition",get:function(){return this.latencyController.liveSyncPosition}},{key:"latency",get:function(){return this.latencyController.latency}},{key:"maxLatency",get:function(){return this.latencyController.maxLatency}},{key:"targetLatency",get:function(){return this.latencyController.targetLatency}},{key:"drift",get:function(){return this.latencyController.drift}},{key:"forceStartLoad",get:function(){return this.streamController.forceStartLoad}}],[{key:"version",get:function(){return"1.5.1"}},{key:"Events",get:function(){return S}},{key:"ErrorTypes",get:function(){return L}},{key:"ErrorDetails",get:function(){return A}},{key:"DefaultConfig",get:function(){return t.defaultConfig?t.defaultConfig:zs},set:function(e){t.defaultConfig=e}}]),t}();return ao.defaultConfig=void 0,ao},"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(r="undefined"!=typeof globalThis?globalThis:r||self).Hls=i()}(!1); +// @license-end diff --git a/src/formatters.nim b/src/formatters.nim index 3630917..8267f23 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -82,6 +82,8 @@ proc proxifyVideo*(manifest: string; proxy: bool): string = for line in manifest.splitLines: let url = if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2] + elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: + line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] else: line if url.startsWith('/'): let path = "https://video.twimg.com" & url diff --git a/src/utils.nim b/src/utils.nim index ede9aed..c96a6dd 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -31,9 +31,7 @@ proc getHmac*(data: string): string = proc getVidUrl*(link: string): string = if link.len == 0: return - let - link = link.replace("cmaf", "fmp4") - sig = getHmac(link) + let sig = getHmac(link) if base64Media: &"/video/enc/{sig}/{encode(link, safe=true)}" else: diff --git a/src/views/general.nim b/src/views/general.nim index 87d30f2..5ba40a3 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -73,7 +73,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") if prefs.hlsPlayback: - script(src="/js/hls.light.min.js", `defer`="") + script(src="/js/hls.min.js", `defer`="") script(src="/js/hlsPlayback.js", `defer`="") if prefs.infiniteScroll: From c6edec04901d0a37799499ed4c6921db640fb5a4 Mon Sep 17 00:00:00 2001 From: somini <somini@users.noreply.github.com> Date: Mon, 26 Feb 2024 03:08:25 +0000 Subject: [PATCH 090/309] Update auth.nim (#1164) Avoid expiring the tokens for now. See: - https://github.com/zedeus/nitter/issues/983#issuecomment-1923046398 - https://github.com/zedeus/nitter/issues/1155#issuecomment-1917167072 Thanks @cmj --- src/auth.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth.nim b/src/auth.nim index b288c50..de1b1d8 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -202,7 +202,7 @@ proc initAccountPool*(cfg: Config; path: string) = quit 1 let accountsPrePurge = accountPool.len - accountPool.keepItIf(not it.hasExpired) + #accountPool.keepItIf(not it.hasExpired) log "Successfully added ", accountPool.len, " valid accounts." if accountsPrePurge > accountPool.len: From 19569bb19f6a725789e45e58bb86fcd7806da8a7 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 00:25:50 +0100 Subject: [PATCH 091/309] Replace old v1 photo rail API with gql --- src/api.nim | 12 +++++------ src/apiutils.nim | 19 ----------------- src/auth.nim | 1 - src/consts.nim | 20 +----------------- src/parser.nim | 45 +++++++++++++++++++++++++---------------- src/redis_cache.nim | 12 +++++------ src/routes/timeline.nim | 2 +- src/types.nim | 1 - 8 files changed, 42 insertions(+), 70 deletions(-) diff --git a/src/api.nim b/src/api.nim index d6a4564..479cb3d 100644 --- a/src/api.nim +++ b/src/api.nim @@ -136,13 +136,13 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} result = parseGraphSearch[User](await fetch(url, Api.search), after) result.query = query -proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = - if name.len == 0: return +proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = + if id.len == 0: return let - ps = genParams({"screen_name": name, "trim_user": "true"}, - count="18", ext=false) - url = photoRail ? ps - result = parsePhotoRail(await fetch(url, Api.photoRail)) + variables = userTweetsVariables % [id, ""] + params = {"variables": variables, "features": gqlFeatures} + url = graphUserMedia ? params + result = parseGraphPhotoRail(await fetch(url, Api.userMedia)) proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) diff --git a/src/apiutils.nim b/src/apiutils.nim index 1ff05eb..75e0e4c 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -10,25 +10,6 @@ const var pool: HttpPool -proc genParams*(pars: openArray[(string, string)] = @[]; cursor=""; - count="20"; ext=true): seq[(string, string)] = - result = timelineParams - for p in pars: - result &= p - if ext: - result &= ("include_ext_alt_text", "1") - result &= ("include_ext_media_stats", "1") - result &= ("include_ext_media_availability", "1") - if count.len > 0: - result &= ("count", count) - if cursor.len > 0: - # The raw cursor often has plus signs, which sometimes get turned into spaces, - # so we need to turn them back into a plus - if " " in cursor: - result &= ("cursor", cursor.replace(" ", "+")) - else: - result &= ("cursor", cursor) - proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = let encodedUrl = url.replace(",", "%2C").replace("+", "%20") diff --git a/src/auth.nim b/src/auth.nim index de1b1d8..a15766b 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -10,7 +10,6 @@ const apiMaxReqs: Table[Api, int] = { Api.search: 50, Api.tweetDetail: 150, - Api.photoRail: 180, Api.userTweets: 500, Api.userTweetsAndReplies: 500, Api.userMedia: 500, diff --git a/src/consts.nim b/src/consts.nim index e1c35e6..3abd1bc 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import uri, sequtils, strutils +import uri, strutils const consumerKey* = "3nVuSoBZnx6U4vzUxf5w" @@ -8,8 +8,6 @@ const api = parseUri("https://api.twitter.com") activate* = $(api / "1.1/guest/activate.json") - photoRail* = api / "1.1/statuses/media_timeline.json" - graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" @@ -24,22 +22,6 @@ const graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" - timelineParams* = { - "include_can_media_tag": "1", - "include_cards": "1", - "include_entities": "1", - "include_profile_interstitial_type": "0", - "include_quote_count": "0", - "include_reply_count": "0", - "include_user_entities": "0", - "include_ext_reply_count": "0", - "include_ext_media_color": "0", - "cards_platform": "Web-13", - "tweet_mode": "extended", - "send_error_codes": "1", - "simple_quoted_tweet": "1" - }.toSeq - gqlFeatures* = """{ "android_graphql_skip_api_media_color_palette": false, "blue_business_profile_image_shape_enabled": false, diff --git a/src/parser.nim b/src/parser.nim index ec856a6..b8812d2 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -289,23 +289,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.text.removeSuffix(" Learn more.") result.available = false -proc parsePhotoRail*(js: JsonNode): PhotoRail = - with error, js{"error"}: - if error.getStr == "Not authorized.": - return - - for tweet in js: - let - t = parseTweet(tweet, js{"tweet_card"}) - url = if t.photos.len > 0: t.photos[0] - elif t.video.isSome: get(t.video).thumb - elif t.gif.isSome: get(t.gif).thumb - elif t.card.isSome: get(t.card).image - else: "" - - if url.len == 0: continue - result.add GalleryPhoto(url: url, tweetId: $t.id) - proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = if js.kind == JNull: return Tweet() @@ -445,6 +428,34 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = tweet.id = parseBiggestInt(entryId) result.pinned = some tweet +proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = + result = @[] + + let instructions = + ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + + for i in instructions: + if i{"__typename"}.getStr == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet"): + with tweetResult, e{"content", "content", "tweetResult", "result"}: + let t = parseGraphTweet(tweetResult, false) + if not t.available: + t.id = parseBiggestInt(entryId.getId()) + + let url = + if t.photos.len > 0: t.photos[0] + elif t.video.isSome: get(t.video).thumb + elif t.gif.isSome: get(t.gif).thumb + elif t.card.isSome: get(t.card).image + else: "" + + result.add GalleryPhoto(url: url, tweetId: $t.id) + + if result.len == 16: + break + proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 1d77cca..559d299 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -86,7 +86,7 @@ proc cache*(data: List) {.async.} = await setEx(data.listKey, listCacheTime, compress(toFlatty(data))) proc cache*(data: PhotoRail; name: string) {.async.} = - await setEx("pr:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data))) + await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data))) proc cache*(data: User) {.async.} = if data.username.len == 0: return @@ -158,14 +158,14 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = # if not result.isNil: # await cache(result) -proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} = - if name.len == 0: return - let rail = await get("pr:" & toLower(name)) +proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = + if id.len == 0: return + let rail = await get("pr2:" & toLower(id)) if rail != redisNil: rail.deserialize(PhotoRail) else: - result = await getPhotoRail(name) - await cache(result, name) + result = await getPhotoRail(id) + await cache(result, id) proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} = let list = if id.len == 0: redisNil diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 3568ab7..8cd1fd7 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -47,7 +47,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; let rail = skipIf(skipRail or query.kind == media, @[]): - getCachedPhotoRail(name) + getCachedPhotoRail(userId) user = getCachedUser(name) diff --git a/src/types.nim b/src/types.nim index ddbebdf..1b9189b 100644 --- a/src/types.nim +++ b/src/types.nim @@ -15,7 +15,6 @@ type Api* {.pure.} = enum tweetDetail tweetResult - photoRail search list listBySlug From 28d3ed7d9fad2302bf357f099c8b8951631dee8c Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 00:32:55 +0100 Subject: [PATCH 092/309] Raise NoAccountsError when all accounts limited --- src/auth.nim | 5 ++++- src/nitter.nim | 5 +++++ src/types.nim | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/auth.nim b/src/auth.nim index a15766b..20e136f 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -125,6 +125,9 @@ proc getAccountPoolDebug*(): JsonNode = proc rateLimitError*(): ref RateLimitError = newException(RateLimitError, "rate limited") +proc noAccountsError*(): ref NoAccountsError = + newException(NoAccountsError, "no accounts available") + proc isLimited(account: GuestAccount; api: Api): bool = if account.isNil: return true @@ -165,7 +168,7 @@ proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} = inc result.pending else: log "no accounts available for API: ", api - raise rateLimitError() + raise noAccountsError() proc setLimited*(account: GuestAccount; api: Api) = account.apis[api].limited = true diff --git a/src/nitter.nim b/src/nitter.nim index dfc1dfd..958dc0b 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -97,6 +97,11 @@ routes: resp Http429, showError( &"Instance has been rate limited.<br>Use {link} or try again later.", cfg) + error NoAccountsError: + const link = a("another instance", href = instancesUrl) + resp Http429, showError( + &"Instance has no available accounts.<br>Use {link} or try again later.", cfg) + extend rss, "" extend status, "" extend search, "" diff --git a/src/types.nim b/src/types.nim index 1b9189b..d1981d7 100644 --- a/src/types.nim +++ b/src/types.nim @@ -6,6 +6,7 @@ genPrefsType() type RateLimitError* = object of CatchableError + NoAccountsError* = object of CatchableError InternalError* = object of CatchableError BadClientError* = object of CatchableError From 1aa9b0dba67145fa052422de93e249df3d92b468 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 00:34:50 +0100 Subject: [PATCH 093/309] Move limited flag to be account-level --- src/auth.nim | 49 ++++++++++++++++++++----------------------------- src/types.nim | 4 ++-- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index 20e136f..66656d3 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -32,13 +32,6 @@ template log(str: varargs[string, `$`]) = proc snowflakeToEpoch(flake: int64): int64 = int64(((flake shr 22) + 1288834974657) div 1000) -proc hasExpired(account: GuestAccount): bool = - let - created = snowflakeToEpoch(account.id) - now = epochTime().int64 - daysOld = int(now - created) div dayInSeconds - return daysOld > 30 - proc getAccountPoolHealth*(): JsonNode = let now = epochTime().int @@ -58,14 +51,14 @@ proc getAccountPoolHealth*(): JsonNode = oldest = created average += created + if account.limited: + limited.incl account.id + for api in account.apis.keys: let apiStatus = account.apis[api] reqs = apiMaxReqs[api] - apiStatus.remaining - if apiStatus.limited: - limited.incl account.id - # no requests made with this account and endpoint since the limit reset if apiStatus.reset < now: continue @@ -103,6 +96,9 @@ proc getAccountPoolDebug*(): JsonNode = "pending": account.pending, } + if account.limited: + accountJson["limited"] = %true + for api in account.apis.keys: let apiStatus = account.apis[api] @@ -110,13 +106,11 @@ proc getAccountPoolDebug*(): JsonNode = if apiStatus.reset > now.int: obj["remaining"] = %apiStatus.remaining + obj["reset"] = %apiStatus.reset - if "remaining" notin obj and not apiStatus.limited: + if "remaining" notin obj: continue - if apiStatus.limited: - obj["limited"] = %true - accountJson{"apis", $api} = obj list[$account.id] = accountJson @@ -132,14 +126,16 @@ proc isLimited(account: GuestAccount; api: Api): bool = if account.isNil: return true + if account.limited and api != Api.userTweets: + if (epochTime().int - account.limitedAt) > dayInSeconds: + account.limited = false + log "resetting limit: ", account.id + else: + return false + if api in account.apis: let limit = account.apis[api] - - if limit.limited and (epochTime().int - limit.limitedAt) > dayInSeconds: - account.apis[api].limited = false - log "resetting limit, api: ", api, ", id: ", account.id - - return limit.limited or (limit.remaining <= 10 and limit.reset > epochTime().int) + return limit.remaining <= 10 and limit.reset > epochTime().int else: return false @@ -148,7 +144,7 @@ proc isReady(account: GuestAccount; api: Api): bool = proc invalidate*(account: var GuestAccount) = if account.isNil: return - log "invalidating expired account: ", account.id + log "invalidating: ", account.id # TODO: This isn't sufficient, but it works for now let idx = accountPool.find(account) @@ -171,9 +167,9 @@ proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} = raise noAccountsError() proc setLimited*(account: GuestAccount; api: Api) = - account.apis[api].limited = true - account.apis[api].limitedAt = epochTime().int - log "rate limited, api: ", api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id + account.limited = true + account.limitedAt = epochTime().int + log "rate limited by api: ", api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id proc setRateLimit*(account: GuestAccount; api: Api; remaining, reset: int) = # avoid undefined behavior in race conditions @@ -203,9 +199,4 @@ proc initAccountPool*(cfg: Config; path: string) = echo "[accounts] ERROR: ", path, " not found. This file is required to authenticate API requests." quit 1 - let accountsPrePurge = accountPool.len - #accountPool.keepItIf(not it.hasExpired) - log "Successfully added ", accountPool.len, " valid accounts." - if accountsPrePurge > accountPool.len: - log "Purged ", accountsPrePurge - accountPool.len, " expired accounts." diff --git a/src/types.nim b/src/types.nim index d1981d7..f8cdda7 100644 --- a/src/types.nim +++ b/src/types.nim @@ -30,14 +30,14 @@ type RateLimit* = object remaining*: int reset*: int - limited*: bool - limitedAt*: int GuestAccount* = ref object id*: int64 oauthToken*: string oauthSecret*: string pending*: int + limited*: bool + limitedAt*: int apis*: Table[Api, RateLimit] Error* = enum From 2e13d7b57c4506b8545845f3d3ec847a8c6ca82b Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 00:35:39 +0100 Subject: [PATCH 094/309] Capture "account locked" API error --- src/apiutils.nim | 9 +++++---- src/types.nim | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 75e0e4c..03de5f1 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -76,8 +76,8 @@ template fetchImpl(result, fetchBody) {.dirty.} = if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) - if errors in {expiredToken, badToken}: - echo "fetch error: ", errors + echo "Fetch error, API: ", api, ", errors: ", errors + if errors in {expiredToken, badToken, locked}: invalidate(account) raise rateLimitError() elif errors in {rateLimited}: @@ -93,6 +93,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = fetchBody if resp.status == $Http400: + echo "ERROR 400, ", api, ": ", result raise newException(InternalError, $url) except InternalError as e: raise e @@ -125,8 +126,8 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = result = newJNull() let error = result.getError - if error in {expiredToken, badToken}: - echo "fetchBody error: ", error + echo "Fetch error, API: ", api, ", error: ", error + if error in {expiredToken, badToken, locked}: invalidate(account) raise rateLimitError() diff --git a/src/types.nim b/src/types.nim index f8cdda7..685fbad 100644 --- a/src/types.nim +++ b/src/types.nim @@ -57,6 +57,7 @@ type tweetNotAuthorized = 179 forbidden = 200 badToken = 239 + locked = 326 noCsrf = 353 tweetUnavailable = 421 tweetCensored = 422 From e38276a63863d8c3ceaf27d593f6542769bfad36 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 00:40:19 +0100 Subject: [PATCH 095/309] Update authority header --- src/apiutils.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 03de5f1..d7842b1 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -35,7 +35,7 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = "authorization": header, "content-type": "application/json", "x-twitter-active-user": "yes", - "authority": "api.twitter.com", + "authority": "api.x.com", "accept-encoding": "gzip", "accept-language": "en-US,en;q=0.9", "accept": "*/*", From 5b6dae5228cb58b81df8c54a1c51cfc634e87fe1 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 01:16:52 +0100 Subject: [PATCH 096/309] Add regex for x.com links --- src/formatters.nim | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 8267f23..7428814 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -11,6 +11,8 @@ const let twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com" twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>""" + xRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?x\.com" + xLinkRegex = re"""<a href="https:\/\/x.com([^"]+)">x\.com(\S+)</a>""" ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase}) @@ -56,12 +58,18 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string = if prefs.replaceYouTube.len > 0 and "youtu" in result: result = result.replace(ytRegex, prefs.replaceYouTube) - if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body): - result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") - result = result.replace(cards, prefs.replaceTwitter & "/cards") - result = result.replace(twRegex, prefs.replaceTwitter) - result = result.replacef(twLinkRegex, a( - prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) + if prefs.replaceTwitter.len > 0: + if tco in result: + result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") + if "x.com" in result: + result = result.replace(xRegex, prefs.replaceTwitter) + result = result.replacef(xLinkRegex, a( + prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) + if "twitter.com" in result: + result = result.replace(cards, prefs.replaceTwitter & "/cards") + result = result.replace(twRegex, prefs.replaceTwitter) + result = result.replacef(twLinkRegex, a( + prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") From 81764ea0f802677d0a4cec4025b721d601e2751f Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 01:19:06 +0100 Subject: [PATCH 097/309] Update endpoint versions, switch tweet endpoint --- src/auth.nim | 2 +- src/consts.nim | 46 ++++++++++++++++++++++++++++++---------------- src/parser.nim | 39 ++++++++++++++++++++++----------------- 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index 66656d3..fe265c9 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -9,7 +9,7 @@ const dayInSeconds = 24 * 60 * 60 apiMaxReqs: Table[Api, int] = { Api.search: 50, - Api.tweetDetail: 150, + Api.tweetDetail: 500, Api.userTweets: 500, Api.userTweetsAndReplies: 500, Api.userMedia: 500, diff --git a/src/consts.nim b/src/consts.nim index 3abd1bc..7c67706 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -5,22 +5,20 @@ const consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" - api = parseUri("https://api.twitter.com") - activate* = $(api / "1.1/guest/activate.json") + gql = parseUri("https://api.x.com") / "graphql" - graphql = api / "graphql" - graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" - graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" - graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2" - graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2" - graphUserMedia* = graphql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" - graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail" - graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" - graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" - graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" - graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" - graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" - graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" + graphUser* = gql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" + graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" + graphUserTweets* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2" + graphUserTweetsAndReplies* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" + graphUserMedia* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" + graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2" + graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" + graphSearchTimeline* = gql / "KI9jCXUx3Ymt-hDKLOZb9Q/SearchTimeline" + graphListById* = gql / "oygmAig8kjn0pKsx_bUadQ/ListByRestId" + graphListBySlug* = gql / "88GTz-IPPWLn1EiU8XoNVg/ListBySlug" + graphListMembers* = gql / "kSmxeqEeelqdHSR7jMnb_w/ListMembers" + graphListTweets* = gql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" gqlFeatures* = """{ "android_graphql_skip_api_media_color_palette": false, @@ -62,7 +60,23 @@ const "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, "verified_phone_label_enabled": false, "vibe_api_enabled": false, - "view_counts_everywhere_api_enabled": false + "view_counts_everywhere_api_enabled": false, + "premium_content_api_read_enabled": false, + "communities_web_enable_tweet_community_results_fetch": false, + "responsive_web_jetfuel_frame": false, + "responsive_web_grok_analyze_button_fetch_trends_enabled": false, + "responsive_web_grok_image_annotation_enabled": false, + "rweb_tipjar_consumption_enabled": false, + "profile_label_improvements_pcf_label_in_post_enabled": false, + "creator_subscriptions_quote_tweet_preview_enabled": false, + "c9s_tweet_anatomy_moderator_badge_enabled": false, + "responsive_web_grok_analyze_post_followups_enabled": false, + "rweb_video_timestamps_enabled": false, + "responsive_web_grok_share_attachment_enabled": false, + "articles_preview_enabled": false, + "immersive_video_status_linkable_timestamps": false, + "articles_api_enabled": false, + "responsive_web_grok_analysis_button_from_backend": false }""".replace(" ", "").replace("\n", "") tweetVariables* = """{ diff --git a/src/parser.nim b/src/parser.nim index b8812d2..4bdb1e2 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -352,18 +352,23 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet, false) -proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = +proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) - let instructions = ? js{"data", "threaded_conversation_with_injections_v2", "instructions"} + let + rootKey = if v2: "timeline_response" else: "threaded_conversation_with_injections_v2" + contentKey = if v2: "content" else: "itemContent" + resultKey = if v2: "tweetResult" else: "tweet_results" + + let instructions = ? js{"data", rootKey, "instructions"} if instructions.len == 0: return for e in instructions[0]{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): - with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetResult, true) + with tweetResult, e{"content", contentKey, resultKey, "result"}: + let tweet = parseGraphTweet(tweetResult, not v2) if not tweet.available: tweet.id = parseBiggestInt(entryId.getId()) @@ -372,26 +377,26 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.tweet = tweet else: result.before.content.add tweet - elif entryId.startsWith("tombstone"): - let id = entryId.getId() - let tweet = Tweet( - id: parseBiggestInt(id), - available: false, - text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone - ) - - if id == tweetId: - result.tweet = tweet - else: - result.before.content.add tweet elif entryId.startsWith("conversationthread"): let (thread, self) = parseGraphThread(e) if self: result.after = thread else: result.replies.content.add thread + elif entryId.startsWith("tombstone"): + let id = entryId.getId() + let tweet = Tweet( + id: parseBiggestInt(id), + available: false, + text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone + ) + + if id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet elif entryId.startsWith("cursor-bottom"): - result.replies.bottom = e{"content", "itemContent", "value"}.getStr + result.replies.bottom = e{"content", contentKey, "value"}.getStr proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) From b43bfc5d4255dacf8d19575959a9407c1444777b Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 01:19:27 +0100 Subject: [PATCH 098/309] Return 403 on hmac error --- src/routes/media.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/media.nim b/src/routes/media.nim index eacd1f8..ac76ab6 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -122,7 +122,7 @@ proc createMediaRouter*(cfg: Config) = cond "http" in url if getHmac(url) != request.matches[1]: - resp showError("Failed to verify signature", cfg) + resp Http403, showError("Failed to verify signature", cfg) if ".mp4" in url or ".ts" in url or ".m4s" in url: let code = await proxyMedia(request, url) From 77288999485d14070f119813a26a9bd21728356b Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 03:18:49 +0100 Subject: [PATCH 099/309] Add lazy loading for images --- src/views/renderutils.nim | 2 +- src/views/tweet.nim | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index f298fad..41ef8df 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -91,7 +91,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") 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 2fe4ac9..34dcd4c 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -10,9 +10,7 @@ import general 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) + genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini")) proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode = buildHtml(tdiv): @@ -92,10 +90,10 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = tdiv(class="attachment video-container"): let thumb = getSmallPic(video.thumb) if not video.available: - img(src=thumb) + img(src=thumb, loading="lazy") renderVideoUnavailable(video) elif not prefs.isPlaybackEnabled(playbackType): - img(src=thumb) + img(src=thumb, loading="lazy") renderVideoDisabled(playbackType, path) else: let @@ -144,7 +142,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="") + genImg(card.image) if card.kind == player: tdiv(class="card-overlay"): tdiv(class="overlay-circle"): From c0f2eea27659815b5bdc692f409bfe675a8892a4 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 03:19:10 +0100 Subject: [PATCH 100/309] Fix missing video thumbnail being too small --- src/sass/tweet/video.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 1e9096e..98a1c29 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -16,6 +16,8 @@ video { } .video-container { + min-height: 80px; + min-width: 200px; max-height: 530px; margin: 0; display: flex; From 5edaea23593ebfc28dcd7632445f0144c6242ef6 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 03:20:05 +0100 Subject: [PATCH 101/309] Silence 404 proxy errors --- src/routes/media.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/media.nim b/src/routes/media.nim index ac76ab6..de51061 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -37,7 +37,8 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = try: let res = await client.get(url) if res.status != "200 OK": - echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] + if res.status != "404 Not Found": + echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] return Http404 let hashed = $hash(url) From 5265de101d63b8d5dade547a8d5a994457643493 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 03:49:17 +0100 Subject: [PATCH 102/309] Skip null fetch errors --- src/apiutils.nim | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index d7842b1..0a6e0d2 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -126,10 +126,11 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = result = newJNull() let error = result.getError - echo "Fetch error, API: ", api, ", error: ", error - if error in {expiredToken, badToken, locked}: - invalidate(account) - raise rateLimitError() + if error != null: + echo "Fetch error, API: ", api, ", error: ", error + if error in {expiredToken, badToken, locked}: + invalidate(account) + raise rateLimitError() proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = retry: From afad55749be0c6e2c51d495e7a22c09bdfdc6410 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 03:49:34 +0100 Subject: [PATCH 103/309] Increase max concurrent reqs per account --- src/auth.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth.nim b/src/auth.nim index fe265c9..85fe4a7 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -5,7 +5,7 @@ import experimental/parser/guestaccount # max requests at a time per account to avoid race conditions const - maxConcurrentReqs = 2 + maxConcurrentReqs = 3 dayInSeconds = 24 * 60 * 60 apiMaxReqs: Table[Api, int] = { Api.search: 50, From 6fcd849eff51ad1ee6e6078e3236896ab97803b6 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 04:09:36 +0100 Subject: [PATCH 104/309] Rename accounts/guest accounts to sessions The new file loaded by default is now ./sessions.jsonl JSONL is also required, .json support dropped. --- .gitignore | 1 + nitter.example.conf | 2 +- src/apiutils.nim | 28 ++-- src/auth.nim | 147 +++++++++--------- src/experimental/parser/guestaccount.nim | 21 --- src/experimental/parser/session.nim | 15 ++ .../types/{guestaccount.nim => session.nim} | 2 +- src/nitter.nim | 8 +- src/routes/debug.nim | 6 +- src/types.nim | 4 +- 10 files changed, 114 insertions(+), 120 deletions(-) delete mode 100644 src/experimental/parser/guestaccount.nim create mode 100644 src/experimental/parser/session.nim rename src/experimental/types/{guestaccount.nim => session.nim} (71%) diff --git a/.gitignore b/.gitignore index ea520dc..dbd2f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ nitter /public/md/*.html nitter.conf guest_accounts.json* +sessions.json* dump.rdb diff --git a/nitter.example.conf b/nitter.example.conf index f0b4214..360e07b 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -23,7 +23,7 @@ redisMaxConnections = 30 hmacKey = "secretkey" # random key for cryptographic signing of video urls base64Media = false # use base64 encoding for proxied media urls enableRSS = true # set this to false to disable RSS feeds -enableDebug = false # enable request logs and debug endpoints (/.accounts) +enableDebug = false # enable request logs and debug endpoints (/.sessions) proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" tokenCount = 10 diff --git a/src/apiutils.nim b/src/apiutils.nim index 0a6e0d2..3a0d12c 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -46,14 +46,14 @@ template fetchImpl(result, fetchBody) {.dirty.} = once: pool = HttpPool() - var account = await getGuestAccount(api) - if account.oauthToken.len == 0: - echo "[accounts] Empty oauth token, account: ", account.id + var session = await getSession(api) + if session.oauthToken.len == 0: + echo "[sessions] Empty oauth token, session: ", session.id raise rateLimitError() try: var resp: AsyncResponse - pool.use(genHeaders($url, account.oauthToken, account.oauthSecret)): + pool.use(genHeaders($url, session.oauthToken, session.oauthSecret)): template getContent = resp = await c.get($url) result = await resp.body @@ -68,7 +68,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = let remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) - account.setRateLimit(api, remaining, reset) + session.setRateLimit(api, remaining, reset) if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": @@ -78,15 +78,15 @@ template fetchImpl(result, fetchBody) {.dirty.} = let errors = result.fromJson(Errors) echo "Fetch error, API: ", api, ", errors: ", errors if errors in {expiredToken, badToken, locked}: - invalidate(account) + invalidate(session) raise rateLimitError() elif errors in {rateLimited}: # rate limit hit, resets after 24 hours - setLimited(account, api) + setLimited(session, api) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): - echo "[accounts] 429 error, API: ", api, ", account: ", account.id - account.apis[api].remaining = 0 + echo "[sessions] 429 error, API: ", api, ", session: ", session.id + session.apis[api].remaining = 0 # rate limit hit, resets after the 15 minute window raise rateLimitError() @@ -102,17 +102,17 @@ template fetchImpl(result, fetchBody) {.dirty.} = except OSError as e: raise e except Exception as e: - let id = if account.isNil: "null" else: $account.id - echo "error: ", e.name, ", msg: ", e.msg, ", accountId: ", id, ", url: ", url + let id = if session.isNil: "null" else: $session.id + echo "error: ", e.name, ", msg: ", e.msg, ", sessionId: ", id, ", url: ", url raise rateLimitError() finally: - release(account) + release(session) template retry(bod) = try: bod except RateLimitError: - echo "[accounts] Rate limited, retrying ", api, " request..." + echo "[sessions] Rate limited, retrying ", api, " request..." bod proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = @@ -129,7 +129,7 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = if error != null: echo "Fetch error, API: ", api, ", error: ", error if error in {expiredToken, badToken, locked}: - invalidate(account) + invalidate(session) raise rateLimitError() proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = diff --git a/src/auth.nim b/src/auth.nim index 85fe4a7..c57008b 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -1,9 +1,9 @@ #SPDX-License-Identifier: AGPL-3.0-only import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os] import types -import experimental/parser/guestaccount +import experimental/parser/session -# max requests at a time per account to avoid race conditions +# max requests at a time per session to avoid race conditions const maxConcurrentReqs = 3 dayInSeconds = 24 * 60 * 60 @@ -23,16 +23,16 @@ const }.toTable var - accountPool: seq[GuestAccount] + sessionPool: seq[Session] enableLogging = false template log(str: varargs[string, `$`]) = - if enableLogging: echo "[accounts] ", str.join("") + if enableLogging: echo "[sessions] ", str.join("") proc snowflakeToEpoch(flake: int64): int64 = int64(((flake shr 22) + 1288834974657) div 1000) -proc getAccountPoolHealth*(): JsonNode = +proc getSessionPoolHealth*(): JsonNode = let now = epochTime().int var @@ -43,38 +43,38 @@ proc getAccountPoolHealth*(): JsonNode = newest = 0'i64 average = 0'i64 - for account in accountPool: - let created = snowflakeToEpoch(account.id) + for session in sessionPool: + let created = snowflakeToEpoch(session.id) if created > newest: newest = created if created < oldest: oldest = created average += created - if account.limited: - limited.incl account.id + if session.limited: + limited.incl session.id - for api in account.apis.keys: + for api in session.apis.keys: let - apiStatus = account.apis[api] + apiStatus = session.apis[api] reqs = apiMaxReqs[api] - apiStatus.remaining - # no requests made with this account and endpoint since the limit reset + # no requests made with this session and endpoint since the limit reset if apiStatus.reset < now: continue reqsPerApi.mgetOrPut($api, 0).inc reqs totalReqs.inc reqs - if accountPool.len > 0: - average = average div accountPool.len + if sessionPool.len > 0: + average = average div sessionPool.len else: oldest = 0 average = 0 return %*{ - "accounts": %*{ - "total": accountPool.len, + "sessions": %*{ + "total": sessionPool.len, "limited": limited.card, "oldest": $fromUnix(oldest), "newest": $fromUnix(newest), @@ -86,22 +86,22 @@ proc getAccountPoolHealth*(): JsonNode = } } -proc getAccountPoolDebug*(): JsonNode = +proc getSessionPoolDebug*(): JsonNode = let now = epochTime().int var list = newJObject() - for account in accountPool: - let accountJson = %*{ + for session in sessionPool: + let sessionJson = %*{ "apis": newJObject(), - "pending": account.pending, + "pending": session.pending, } - if account.limited: - accountJson["limited"] = %true + if session.limited: + sessionJson["limited"] = %true - for api in account.apis.keys: + for api in session.apis.keys: let - apiStatus = account.apis[api] + apiStatus = session.apis[api] obj = %*{} if apiStatus.reset > now.int: @@ -111,92 +111,91 @@ proc getAccountPoolDebug*(): JsonNode = if "remaining" notin obj: continue - accountJson{"apis", $api} = obj - list[$account.id] = accountJson + sessionJson{"apis", $api} = obj + list[$session.id] = sessionJson return %list proc rateLimitError*(): ref RateLimitError = newException(RateLimitError, "rate limited") -proc noAccountsError*(): ref NoAccountsError = - newException(NoAccountsError, "no accounts available") +proc noSessionsError*(): ref NoSessionsError = + newException(NoSessionsError, "no sessions available") -proc isLimited(account: GuestAccount; api: Api): bool = - if account.isNil: +proc isLimited(session: Session; api: Api): bool = + if session.isNil: return true - if account.limited and api != Api.userTweets: - if (epochTime().int - account.limitedAt) > dayInSeconds: - account.limited = false - log "resetting limit: ", account.id + if session.limited and api != Api.userTweets: + if (epochTime().int - session.limitedAt) > dayInSeconds: + session.limited = false + log "resetting limit: ", session.id else: return false - if api in account.apis: - let limit = account.apis[api] + if api in session.apis: + let limit = session.apis[api] return limit.remaining <= 10 and limit.reset > epochTime().int else: return false -proc isReady(account: GuestAccount; api: Api): bool = - not (account.isNil or account.pending > maxConcurrentReqs or account.isLimited(api)) +proc isReady(session: Session; api: Api): bool = + not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api)) -proc invalidate*(account: var GuestAccount) = - if account.isNil: return - log "invalidating: ", account.id +proc invalidate*(session: var Session) = + if session.isNil: return + log "invalidating: ", session.id # TODO: This isn't sufficient, but it works for now - let idx = accountPool.find(account) - if idx > -1: accountPool.delete(idx) - account = nil + let idx = sessionPool.find(session) + if idx > -1: sessionPool.delete(idx) + session = nil -proc release*(account: GuestAccount) = - if account.isNil: return - dec account.pending +proc release*(session: Session) = + if session.isNil: return + dec session.pending -proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} = - for i in 0 ..< accountPool.len: +proc getSession*(api: Api): Future[Session] {.async.} = + for i in 0 ..< sessionPool.len: if result.isReady(api): break - result = accountPool.sample() + result = sessionPool.sample() if not result.isNil and result.isReady(api): inc result.pending else: - log "no accounts available for API: ", api - raise noAccountsError() + log "no sessions available for API: ", api + raise noSessionsError() -proc setLimited*(account: GuestAccount; api: Api) = - account.limited = true - account.limitedAt = epochTime().int - log "rate limited by api: ", api, ", reqs left: ", account.apis[api].remaining, ", id: ", account.id +proc setLimited*(session: Session; api: Api) = + session.limited = true + session.limitedAt = epochTime().int + log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", id: ", session.id -proc setRateLimit*(account: GuestAccount; api: Api; remaining, reset: int) = +proc setRateLimit*(session: Session; api: Api; remaining, reset: int) = # avoid undefined behavior in race conditions - if api in account.apis: - let limit = account.apis[api] + if api in session.apis: + let limit = session.apis[api] if limit.reset >= reset and limit.remaining < remaining: return if limit.reset == reset and limit.remaining >= remaining: - account.apis[api].remaining = remaining + session.apis[api].remaining = remaining return - account.apis[api] = RateLimit(remaining: remaining, reset: reset) + session.apis[api] = RateLimit(remaining: remaining, reset: reset) -proc initAccountPool*(cfg: Config; path: string) = +proc initSessionPool*(cfg: Config; path: string) = enableLogging = cfg.enableDebug - let jsonlPath = if path.endsWith(".json"): (path & 'l') else: path - - if fileExists(jsonlPath): - log "Parsing JSONL guest accounts file: ", jsonlPath - for line in jsonlPath.lines: - accountPool.add parseGuestAccount(line) - elif fileExists(path): - log "Parsing JSON guest accounts file: ", path - accountPool = parseGuestAccounts(path) - else: - echo "[accounts] ERROR: ", path, " not found. This file is required to authenticate API requests." + if path.endsWith(".json"): + echo "[sessions] ERROR: .json is not supported, the file must be a valid JSONL file ending in .jsonl" quit 1 - log "Successfully added ", accountPool.len, " valid accounts." + if not fileExists(path): + echo "[sessions] ERROR: ", path, " not found. This file is required to authenticate API requests." + quit 1 + + log "Parsing JSONL account sessions file: ", path + for line in path.lines: + sessionPool.add parseSession(line) + + log "Successfully added ", sessionPool.len, " valid account sessions." diff --git a/src/experimental/parser/guestaccount.nim b/src/experimental/parser/guestaccount.nim deleted file mode 100644 index f7e6d34..0000000 --- a/src/experimental/parser/guestaccount.nim +++ /dev/null @@ -1,21 +0,0 @@ -import std/strutils -import jsony -import ../types/guestaccount -from ../../types import GuestAccount - -proc toGuestAccount(account: RawAccount): GuestAccount = - let id = account.oauthToken[0 ..< account.oauthToken.find('-')] - result = GuestAccount( - id: parseBiggestInt(id), - oauthToken: account.oauthToken, - oauthSecret: account.oauthTokenSecret - ) - -proc parseGuestAccount*(raw: string): GuestAccount = - let rawAccount = raw.fromJson(RawAccount) - result = rawAccount.toGuestAccount - -proc parseGuestAccounts*(path: string): seq[GuestAccount] = - let rawAccounts = readFile(path).fromJson(seq[RawAccount]) - for account in rawAccounts: - result.add account.toGuestAccount diff --git a/src/experimental/parser/session.nim b/src/experimental/parser/session.nim new file mode 100644 index 0000000..ee9c93e --- /dev/null +++ b/src/experimental/parser/session.nim @@ -0,0 +1,15 @@ +import std/strutils +import jsony +import ../types/session +from ../../types import Session + +proc parseSession*(raw: string): Session = + let + session = raw.fromJson(RawSession) + id = session.oauthToken[0 ..< session.oauthToken.find('-')] + + result = Session( + id: parseBiggestInt(id), + oauthToken: session.oauthToken, + oauthSecret: session.oauthTokenSecret + ) diff --git a/src/experimental/types/guestaccount.nim b/src/experimental/types/session.nim similarity index 71% rename from src/experimental/types/guestaccount.nim rename to src/experimental/types/session.nim index 244edb3..4165204 100644 --- a/src/experimental/types/guestaccount.nim +++ b/src/experimental/types/session.nim @@ -1,4 +1,4 @@ type - RawAccount* = object + RawSession* = object oauthToken*: string oauthTokenSecret*: string diff --git a/src/nitter.nim b/src/nitter.nim index 958dc0b..f81dc1c 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -19,9 +19,9 @@ let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") (cfg, fullCfg) = getConfig(configPath) - accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json") + sessionsPath = getEnv("NITTER_SESSIONS_FILE", "./sessions.jsonl") -initAccountPool(cfg, accountsPath) +initSessionPool(cfg, sessionsPath) if not cfg.enableDebug: # Silence Jester's query warning @@ -97,10 +97,10 @@ routes: resp Http429, showError( &"Instance has been rate limited.<br>Use {link} or try again later.", cfg) - error NoAccountsError: + error NoSessionsError: const link = a("another instance", href = instancesUrl) resp Http429, showError( - &"Instance has no available accounts.<br>Use {link} or try again later.", cfg) + &"Instance has no auth tokens, or is fully rate limited.<br>Use {link} or try again later.", cfg) extend rss, "" extend status, "" diff --git a/src/routes/debug.nim b/src/routes/debug.nim index 895a285..97c5bef 100644 --- a/src/routes/debug.nim +++ b/src/routes/debug.nim @@ -6,8 +6,8 @@ import ".."/[auth, types] proc createDebugRouter*(cfg: Config) = router debug: get "/.health": - respJson getAccountPoolHealth() + respJson getSessionPoolHealth() - get "/.accounts": + get "/.sessions": cond cfg.enableDebug - respJson getAccountPoolDebug() + respJson getSessionPoolDebug() diff --git a/src/types.nim b/src/types.nim index 685fbad..6c1f1b6 100644 --- a/src/types.nim +++ b/src/types.nim @@ -6,7 +6,7 @@ genPrefsType() type RateLimitError* = object of CatchableError - NoAccountsError* = object of CatchableError + NoSessionsError* = object of CatchableError InternalError* = object of CatchableError BadClientError* = object of CatchableError @@ -31,7 +31,7 @@ type remaining*: int reset*: int - GuestAccount* = ref object + Session* = ref object id*: int64 oauthToken*: string oauthSecret*: string From 4d5091947c81e181f34f173e4fe0c423e163f9ed Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 04:24:35 +0100 Subject: [PATCH 105/309] Update Dockerfiles --- .github/workflows/run-tests.yml | 4 ++-- Dockerfile | 4 ++-- Dockerfile.arm64 | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ee28e33..a273771 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,9 +14,9 @@ jobs: strategy: matrix: nim: - - "1.6.10" - "1.6.x" - "2.0.x" + - "2.2.x" - "devel" steps: - uses: actions/checkout@v3 @@ -49,7 +49,7 @@ jobs: sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf nimble md nimble scss - echo '${{ secrets.GUEST_ACCOUNTS }}' > ./guest_accounts.jsonl + echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl - name: Run tests run: | ./nitter & diff --git a/Dockerfile b/Dockerfile index 138dc64..ab442ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.0.0-alpine-regular as nim +FROM nimlang/nim:2.2.0-alpine-regular as nim LABEL maintainer="setenforce@protonmail.com" RUN apk --no-cache add libsass-dev pcre @@ -9,7 +9,7 @@ COPY nitter.nimble . RUN nimble install -y --depsOnly COPY . . -RUN nimble build -d:danger -d:lto -d:strip \ +RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ && nimble scss \ && nimble md diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 70024b2..bf6011f 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -1,7 +1,7 @@ -FROM alpine:3.18 as nim +FROM alpine:3.21.2 as nim LABEL maintainer="setenforce@protonmail.com" -RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.14-r0" "nimble=0.13.1-r2" +RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble WORKDIR /src/nitter @@ -9,13 +9,13 @@ COPY nitter.nimble . RUN nimble install -y --depsOnly COPY . . -RUN nimble build -d:danger -d:lto -d:strip \ +RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ && nimble scss \ && nimble md -FROM alpine:3.18 +FROM alpine:3.21.2 WORKDIR /src/ -RUN apk --no-cache add pcre ca-certificates openssl1.1-compat +RUN apk --no-cache add pcre ca-certificates openssl COPY --from=nim /src/nitter/nitter ./ COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf COPY --from=nim /src/nitter/public ./public From a3d341e7a68f721fa9f43a5849daebd1959b174c Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 18:10:15 +0100 Subject: [PATCH 106/309] Update README, added an important note --- README.md | 57 +++++++++++++++++++++++++++------------------- public/md/about.md | 44 ++++++++++++++++++----------------- 2 files changed, 57 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4f8235d..05c2be4 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,35 @@ [![Test Matrix](https://github.com/zedeus/nitter/workflows/Docker/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml) [![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license) +> [!NOTE] +> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \ +> This does not affect users. \ +> For instructions on how to obtain session tokens, see [Creating session tokens](https://github.com/zedeus/nitter/wiki/Creating-session-tokens). + A free and open source alternative Twitter front-end focused on privacy and performance. \ -Inspired by the [Invidious](https://github.com/iv-org/invidious) -project. +Inspired by the [Invidious](https://github.com/iv-org/invidious) project. - No JavaScript or ads - All requests go through the backend, client never talks to Twitter - Prevents Twitter from tracking your IP or JavaScript fingerprint -- Uses Twitter's unofficial API (no rate limits or developer account required) +- Uses Twitter's unofficial API (no developer account required) - Lightweight (for [@nim_lang](https://nitter.net/nim_lang), 60KB vs 784KB from twitter.com) - RSS feeds - Themes - Mobile support (responsive design) - AGPLv3 licensed, no proprietary instances permitted -Liberapay: https://liberapay.com/zedeus \ -Patreon: https://patreon.com/nitter \ -BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \ -ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \ -LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \ -XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL +<details> +<summary>Donations</summary> +Liberapay: https://liberapay.com/zedeus<br> +Patreon: https://patreon.com/nitter<br> +BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55<br> +ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460<br> +XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL<br> +SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW<br> +ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt +</details> ## Roadmap @@ -42,12 +50,13 @@ maintained by the community. ## Why? -It's impossible to use Twitter without JavaScript enabled. For privacy-minded -folks, preventing JavaScript analytics and IP-based tracking is important, but -apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind -a VPN and using heavy-duty adblockers, you can get accurately tracked with your -[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/), -[no JavaScript required](https://noscriptfingerprint.com/). This all became +It's impossible to use Twitter without JavaScript enabled, and as of 2024 you +need to sign up. For privacy-minded folks, preventing JavaScript analytics and +IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix, +it's impossible. Despite being behind a VPN and using heavy-duty adblockers, +you can get accurately tracked with your [browser's +fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no +JavaScript required](https://noscriptfingerprint.com/). This all became particularly important after Twitter [removed the ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws) for users to control whether their data gets sent to advertisers. @@ -71,19 +80,21 @@ Twitter account. - libpcre - libsass -- redis +- redis/valkey To compile Nitter you need a Nim installation, see -[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible to -install it system-wide or in the user directory you create below. +[nim-lang.org](https://nim-lang.org/install.html) for details. It is possible +to install it system-wide or in the user directory you create below. To compile the scss files, you need to install `libsass`. On Ubuntu and Debian, you can use `libsass-dev`. -Redis is required for caching and in the future for account info. It should be -available on most distros as `redis` or `redis-server` (Ubuntu/Debian). -Running it with the default config is fine, Nitter's default config is set to -use the default Redis port and localhost. +Redis is required for caching and in the future for account info. As of 2024 +Redis is no longer open source, so using the fork Valkey is recommended. It +should be available on most distros as `redis` or `redis-server` +(Ubuntu/Debian), or `valkey`/`valkey-server`. Running it with the default +config is fine, Nitter's default config is set to use the default port and +localhost. Here's how to create a `nitter` user, clone the repo, and build the project along with the scss and md files. @@ -93,7 +104,7 @@ along with the scss and md files. # su nitter $ git clone https://github.com/zedeus/nitter $ cd nitter -$ nimble build -d:release +$ nimble build -d:danger --mm:refc $ nimble scss $ nimble md $ cp nitter.example.conf nitter.conf diff --git a/public/md/about.md b/public/md/about.md index c0adda9..3825e8f 100644 --- a/public/md/about.md +++ b/public/md/about.md @@ -4,15 +4,15 @@ Nitter is a free and open source alternative Twitter front-end focused on privacy and performance. The source is available on GitHub at <https://github.com/zedeus/nitter> -* No JavaScript or ads -* All requests go through the backend, client never talks to Twitter -* Prevents Twitter from tracking your IP or JavaScript fingerprint -* Uses Twitter's unofficial API (no rate limits or developer account required) -* Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com) -* RSS feeds -* Themes -* Mobile support (responsive design) -* AGPLv3 licensed, no proprietary instances permitted +- No JavaScript or ads +- All requests go through the backend, client never talks to Twitter +- Prevents Twitter from tracking your IP or JavaScript fingerprint +- Uses Twitter's unofficial API (no developer account required) +- Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com) +- RSS feeds +- Themes +- Mobile support (responsive design) +- AGPLv3 licensed, no proprietary instances permitted Nitter's GitHub wiki contains [instances](https://github.com/zedeus/nitter/wiki/Instances) and @@ -21,12 +21,13 @@ maintained by the community. ## Why use Nitter? -It's impossible to use Twitter without JavaScript enabled. For privacy-minded -folks, preventing JavaScript analytics and IP-based tracking is important, but -apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind -a VPN and using heavy-duty adblockers, you can get accurately tracked with your -[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/), -[no JavaScript required](https://noscriptfingerprint.com/). This all became +It's impossible to use Twitter without JavaScript enabled, and as of 2024 you +need to sign up. For privacy-minded folks, preventing JavaScript analytics and +IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix, +it's impossible. Despite being behind a VPN and using heavy-duty adblockers, +you can get accurately tracked with your [browser's +fingerprint](https://restoreprivacy.com/browser-fingerprinting/), [no +JavaScript required](https://noscriptfingerprint.com/). This all became particularly important after Twitter [removed the ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws) for users to control whether their data gets sent to advertisers. @@ -42,12 +43,13 @@ Twitter account. ## Donating -Liberapay: <https://liberapay.com/zedeus> \ -Patreon: <https://patreon.com/nitter> \ -BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \ -ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \ -LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \ -XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL +Liberapay: https://liberapay.com/zedeus \ +Patreon: https://patreon.com/nitter \ +BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55 \ +ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460 \ +XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL \ +SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW \ +ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt ## Contact From 10b1d9c80f7c8c70f86f12c6ea4f0889651521bd Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 19:03:47 +0100 Subject: [PATCH 107/309] Add Python script to create account sessions --- tools/get_session.py | 161 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tools/get_session.py diff --git a/tools/get_session.py b/tools/get_session.py new file mode 100644 index 0000000..9ff704d --- /dev/null +++ b/tools/get_session.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +import requests +import json +import sys +import pyotp + +# NOTE: pyotp and requests are dependencies +# > pip install pyotp requests + +TW_CONSUMER_KEY = '3nVuSoBZnx6U4vzUxf5w' +TW_CONSUMER_SECRET = 'Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys' + +def auth(username, password, otp_secret): + bearer_token_req = requests.post("https://api.twitter.com/oauth2/token", + auth=(TW_CONSUMER_KEY, TW_CONSUMER_SECRET), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data='grant_type=client_credentials' + ).json() + bearer_token = ' '.join(str(x) for x in bearer_token_req.values()) + + guest_token = requests.post( + "https://api.twitter.com/1.1/guest/activate.json", + headers={'Authorization': bearer_token} + ).json().get('guest_token') + + if not guest_token: + print("Failed to obtain guest token.") + sys.exit(1) + + twitter_header = { + 'Authorization': bearer_token, + "Content-Type": "application/json", + "User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9 (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)", + "X-Twitter-API-Version": '5', + "X-Twitter-Client": "TwitterAndroid", + "X-Twitter-Client-Version": "10.21.0-release.0", + "OS-Version": "28", + "System-User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9; ONEPLUS A3010 Build/PKQ1.181203.001)", + "X-Twitter-Active-User": "yes", + "X-Guest-Token": guest_token, + "X-Twitter-Client-DeviceID": "" + } + + session = requests.Session() + session.headers = twitter_header + + task1 = session.post( + 'https://api.twitter.com/1.1/onboarding/task.json', + params={ + 'flow_name': 'login', + 'api_version': '1', + 'known_device_token': '', + 'sim_country_code': 'us' + }, + json={ + "flow_token": None, + "input_flow_data": { + "country_code": None, + "flow_context": { + "referrer_context": { + "referral_details": "utm_source=google-play&utm_medium=organic", + "referrer_url": "" + }, + "start_location": { + "location": "deeplink" + } + }, + "requested_variant": None, + "target_user_id": 0 + } + } + ) + + session.headers['att'] = task1.headers.get('att') + + task2 = session.post( + 'https://api.twitter.com/1.1/onboarding/task.json', + json={ + "flow_token": task1.json().get('flow_token'), + "subtask_inputs": [{ + "enter_text": { + "suggestion_id": None, + "text": username, + "link": "next_link" + }, + "subtask_id": "LoginEnterUserIdentifier" + }] + } + ) + + task3 = session.post( + 'https://api.twitter.com/1.1/onboarding/task.json', + json={ + "flow_token": task2.json().get('flow_token'), + "subtask_inputs": [{ + "enter_password": { + "password": password, + "link": "next_link" + }, + "subtask_id": "LoginEnterPassword" + }], + } + ) + + for t3_subtask in task3.json().get('subtasks', []): + if "open_account" in t3_subtask: + return t3_subtask["open_account"] + elif "enter_text" in t3_subtask: + response_text = t3_subtask["enter_text"]["hint_text"] + totp = pyotp.TOTP(otp_secret) + generated_code = totp.now() + task4resp = session.post( + "https://api.twitter.com/1.1/onboarding/task.json", + json={ + "flow_token": task3.json().get("flow_token"), + "subtask_inputs": [ + { + "enter_text": { + "suggestion_id": None, + "text": generated_code, + "link": "next_link", + }, + "subtask_id": "LoginTwoFactorAuthChallenge", + } + ], + } + ) + task4 = task4resp.json() + for t4_subtask in task4.get("subtasks", []): + if "open_account" in t4_subtask: + return t4_subtask["open_account"] + + return None + +if __name__ == "__main__": + if len(sys.argv) != 5: + print("Usage: python3 get_session.py <username> <password> <2fa secret> <path>") + sys.exit(1) + + username = sys.argv[1] + password = sys.argv[2] + otp_secret = sys.argv[3] + path = sys.argv[4] + + result = auth(username, password, otp_secret) + if result is None: + print("Authentication failed.") + sys.exit(1) + + session_entry = { + "oauth_token": result.get("oauth_token"), + "oauth_token_secret": result.get("oauth_token_secret") + } + + try: + with open(path, "a") as f: + f.write(json.dumps(session_entry) + "\n") + print("Authentication successful. Session appended to", path) + except Exception as e: + print(f"Failed to write session information: {e}") + sys.exit(1) From b9af77a9bd460297b9063490d78006af05345e28 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 19:28:10 +0100 Subject: [PATCH 108/309] Change main page search to "Tweets" search --- src/views/search.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/search.nim b/src/views/search.nim index 401e6da..9f7fc95 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -24,9 +24,9 @@ proc renderSearch*(): VNode = buildHtml(tdiv(class="panel-container")): tdiv(class="search-bar"): form(`method`="get", action="/search", autocomplete="off"): - hiddenField("f", "users") + hiddenField("f", "tweets") input(`type`="text", name="q", autofocus="", - placeholder="Enter username...", dir="auto") + placeholder="Search...", dir="auto") button(`type`="submit"): icon "search" proc renderProfileTabs*(query: Query; username: string): VNode = From 066407474921f6b69efdd0bec17d686830f868c4 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 19:37:22 +0100 Subject: [PATCH 109/309] Remove old tokenCount from nitter.example.conf --- nitter.example.conf | 6 ------ 1 file changed, 6 deletions(-) diff --git a/nitter.example.conf b/nitter.example.conf index 360e07b..bddb9a4 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -26,12 +26,6 @@ enableRSS = true # set this to false to disable RSS feeds enableDebug = false # enable request logs and debug endpoints (/.sessions) proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" -tokenCount = 10 -# minimum amount of usable tokens. tokens are used to authorize API requests, -# but they expire after ~1 hour, and have a limit of 500 requests per endpoint. -# the limits reset every 15 minutes, and the pool is filled up so there's -# always at least `tokenCount` usable tokens. only increase this if you receive -# major bursts all the time and don't have a rate limiting setup via e.g. nginx # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] From bc38315d1242bbaf5ceaa6836140975dd302f9e9 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 20:19:18 +0100 Subject: [PATCH 110/309] Fix tests --- .github/workflows/run-tests.yml | 2 +- tests/test_card.py | 4 ++-- tests/test_profile.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a273771..da92525 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -53,4 +53,4 @@ jobs: - name: Run tests run: | ./nitter & - pytest -n8 tests + pytest -n3 tests diff --git a/tests/test_card.py b/tests/test_card.py index 8da91a2..05b55f6 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -26,8 +26,8 @@ no_thumb = [ 'lnkd.in'], ['Thom_Wolf/status/1122466524860702729', - 'facebookresearch/fairseq', - 'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.', + 'GitHub - NVIDIA/Megatron-LM: Ongoing research training transformer models at scale', + 'Ongoing research training transformer models at scale - NVIDIA/Megatron-LM', 'github.com'], ['brent_p/status/1088857328680488961', diff --git a/tests/test_profile.py b/tests/test_profile.py index 38c5189..ea05add 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -4,7 +4,7 @@ from parameterized import parameterized profiles = [ ['mobile_test', 'Test account', 'Test Account. test test Testing username with @mobile_test_2 and a #hashtag', - 'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '98'], + 'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '97'], ['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13'] ] From 54ba1e30b5b0be36c36c8de1626c4bed6d7f82ff Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 20:27:23 +0100 Subject: [PATCH 111/309] Fix empty image URLs in photo rail --- src/parser.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/parser.nim b/src/parser.nim index 4bdb1e2..6e0be73 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -456,7 +456,8 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = elif t.card.isSome: get(t.card).image else: "" - result.add GalleryPhoto(url: url, tweetId: $t.id) + if url.len > 0: + result.add GalleryPhoto(url: url, tweetId: $t.id) if result.len == 16: break From fb7c1d87108f8e909448ed1707ebbc59e5e62325 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 5 Feb 2025 21:48:29 +0100 Subject: [PATCH 112/309] Improve test workflow --- .github/workflows/run-tests.yml | 98 +++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index da92525..379f901 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,49 +8,101 @@ on: - master workflow_call: +# Ensure that multiple runs on the same branch do not overlap. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + jobs: - test: + build-test: + name: Build and test runs-on: buildjet-2vcpu-ubuntu-2204 strategy: matrix: - nim: - - "1.6.x" - - "2.0.x" - - "2.2.x" - - "devel" + nim: ["1.6.x", "2.0.x", "2.2.x", "devel"] steps: - - uses: actions/checkout@v3 + - name: Checkout Code + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Cache nimble + + - name: Cache Nimble Dependencies id: cache-nimble - uses: buildjet/cache@v3 + uses: buildjet/cache@v4 with: path: ~/.nimble - key: ${{ matrix.nim }}-nimble-${{ hashFiles('*.nimble') }} + key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} restore-keys: | - ${{ matrix.nim }}-nimble- - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - cache: "pip" - - uses: jiro4989/setup-nim-action@v1 + ${{ matrix.nim }}-nimble-v2- + + - name: Setup Nim + uses: jiro4989/setup-nim-action@v2 with: nim-version: ${{ matrix.nim }} + use-nightlies: true repo-token: ${{ secrets.GITHUB_TOKEN }} - - run: nimble build -d:release -Y - - run: pip install seleniumbase - - run: seleniumbase install chromedriver - - uses: supercharge/redis-github-action@1.5.0 - - name: Prepare Nitter + + - name: Build Project + run: nimble build -d:release -Y + + integration-test: + needs: [build-test] + name: Integration test + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Cache Nimble Dependencies + id: cache-nimble + uses: buildjet/cache@v4 + with: + path: ~/.nimble + key: devel-nimble-v2-${{ hashFiles('*.nimble') }} + restore-keys: | + devel-nimble-v2- + + - name: Setup Python (3.10) with pip cache + uses: buildjet/setup-python@v4 + with: + python-version: "3.10" + cache: pip + + - name: Setup Nim + uses: jiro4989/setup-nim-action@v2 + with: + nim-version: devel + use-nightlies: true + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Project + run: nimble build -d:release -Y + + - name: Install SeleniumBase and Chromedriver run: | - sudo apt install libsass-dev -y + pip install seleniumbase + seleniumbase install chromedriver + + - name: Start Redis Service + uses: supercharge/redis-github-action@1.5.0 + + - name: Prepare Nitter Environment + run: | + sudo apt-get update && sudo apt-get install -y libsass-dev cp nitter.example.conf nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf nimble md nimble scss + echo '${{ secrets.SESSIONS }}' | head -n1 echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl - - name: Run tests + + - name: Run Tests run: | ./nitter & pytest -n3 tests From 770257636980bf526e1e6a1da54fc3dca8bb6550 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 12 Feb 2025 14:42:05 +0100 Subject: [PATCH 113/309] Fix GitHub workflow secrets permissions --- .github/workflows/build-docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 765e7a0..c9f0392 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -10,6 +10,7 @@ on: jobs: tests: uses: ./.github/workflows/run-tests.yml + secrets: inherit build-docker-amd64: needs: [tests] runs-on: buildjet-2vcpu-ubuntu-2204 From 5be37737eb43902fc1dc45c4862637c1d3fd3178 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 12 Feb 2025 18:36:29 +0100 Subject: [PATCH 114/309] Fix rate limit handling --- src/auth.nim | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index c57008b..bf3c957 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -5,7 +5,7 @@ import experimental/parser/session # max requests at a time per session to avoid race conditions const - maxConcurrentReqs = 3 + maxConcurrentReqs = 2 dayInSeconds = 24 * 60 * 60 apiMaxReqs: Table[Api, int] = { Api.search: 50, @@ -130,8 +130,9 @@ proc isLimited(session: Session; api: Api): bool = if (epochTime().int - session.limitedAt) > dayInSeconds: session.limited = false log "resetting limit: ", session.id - else: return false + else: + return true if api in session.apis: let limit = session.apis[api] From 6da152db07b5adc424e34488b6785550bdc5b863 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 12 Feb 2025 20:43:45 +0100 Subject: [PATCH 115/309] Reduce integration test concurrency --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 379f901..6d02dd2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -105,4 +105,4 @@ jobs: - name: Run Tests run: | ./nitter & - pytest -n3 tests + pytest -n2 tests From 9ccfd8ee99da7825b288c5f34960017800bedb7a Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 12 Feb 2025 21:22:09 +0100 Subject: [PATCH 116/309] Reduce integration test concurrency even more --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6d02dd2..46e4ace 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -105,4 +105,4 @@ jobs: - name: Run Tests run: | ./nitter & - pytest -n2 tests + pytest -n1 tests From 92cd6abcf6d9935bc0d7f013acbfbfd8ddd896ba Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 16 Feb 2025 02:06:19 +0100 Subject: [PATCH 117/309] Stop logging unimportant errors --- src/apiutils.nim | 20 +++++++++++--------- src/types.nim | 3 +++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 3a0d12c..65dcc29 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -7,6 +7,7 @@ import experimental/types/common const rlRemaining = "x-rate-limit-remaining" rlReset = "x-rate-limit-reset" + errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} var pool: HttpPool @@ -76,14 +77,15 @@ template fetchImpl(result, fetchBody) {.dirty.} = if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) - echo "Fetch error, API: ", api, ", errors: ", errors - if errors in {expiredToken, badToken, locked}: - invalidate(session) - raise rateLimitError() - elif errors in {rateLimited}: - # rate limit hit, resets after 24 hours - setLimited(session, api) - raise rateLimitError() + if errors notin errorsToSkip: + echo "Fetch error, API: ", api, ", errors: ", errors + if errors in {expiredToken, badToken, locked}: + invalidate(session) + raise rateLimitError() + elif errors in {rateLimited}: + # rate limit hit, resets after 24 hours + setLimited(session, api) + raise rateLimitError() elif result.startsWith("429 Too Many Requests"): echo "[sessions] 429 error, API: ", api, ", session: ", session.id session.apis[api].remaining = 0 @@ -126,7 +128,7 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = result = newJNull() let error = result.getError - if error != null: + if error != null and error notin errorsToSkip: echo "Fetch error, API: ", api, ", error: ", error if error in {expiredToken, badToken, locked}: invalidate(session) diff --git a/src/types.nim b/src/types.nim index 6c1f1b6..4e565ee 100644 --- a/src/types.nim +++ b/src/types.nim @@ -45,8 +45,10 @@ type noUserMatches = 17 protectedUser = 22 missingParams = 25 + timeout = 29 couldntAuth = 32 doesntExist = 34 + unauthorized = 37 invalidParam = 47 userNotFound = 50 suspended = 63 @@ -56,6 +58,7 @@ type tweetNotFound = 144 tweetNotAuthorized = 179 forbidden = 200 + badRequest = 214 badToken = 239 locked = 326 noCsrf = 353 From cc28d21a622be1787f120ce536929f736454a4e7 Mon Sep 17 00:00:00 2001 From: SoonKhen OwYong <dude@owyong.sk> Date: Sat, 22 Feb 2025 08:36:04 -0800 Subject: [PATCH 118/309] Add mounting of sessions.jsonl in docker-compose.yml (#1221) --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index ec8ade5..3d75751 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy volumes: - ./nitter.conf:/src/nitter.conf:Z,ro + - ./sessions.jsonl:/src/sessions.jsonl:Z,ro # Run get_sessions.py to get the credentials depends_on: - nitter-redis restart: unless-stopped From cb334a7d6871f6d108051baea3dd73df2c6fd5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89milien=20=28perso=29?= <4016501+unixfox@users.noreply.github.com> Date: Sun, 23 Feb 2025 23:07:45 +0100 Subject: [PATCH 119/309] chore: Revert back to nim 2.0 for alpine ARM64 (#1222) --- Dockerfile.arm64 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index bf6011f..46352c7 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -1,4 +1,4 @@ -FROM alpine:3.21.2 as nim +FROM alpine:3.20.6 as nim LABEL maintainer="setenforce@protonmail.com" RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble @@ -13,7 +13,7 @@ RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ && nimble scss \ && nimble md -FROM alpine:3.21.2 +FROM alpine:3.20.6 WORKDIR /src/ RUN apk --no-cache add pcre ca-certificates openssl COPY --from=nim /src/nitter/nitter ./ From 4f9ba9c7d69c33d1273ad7a6cd6415c3d0b5c903 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Tue, 25 Feb 2025 04:28:45 +0000 Subject: [PATCH 120/309] Always print sessions logs --- src/auth.nim | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index bf3c957..0ff40f8 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -27,7 +27,7 @@ var enableLogging = false template log(str: varargs[string, `$`]) = - if enableLogging: echo "[sessions] ", str.join("") + echo "[sessions] ", str.join("") proc snowflakeToEpoch(flake: int64): int64 = int64(((flake shr 22) + 1288834974657) div 1000) @@ -188,15 +188,15 @@ proc initSessionPool*(cfg: Config; path: string) = enableLogging = cfg.enableDebug if path.endsWith(".json"): - echo "[sessions] ERROR: .json is not supported, the file must be a valid JSONL file ending in .jsonl" + log "ERROR: .json is not supported, the file must be a valid JSONL file ending in .jsonl" quit 1 if not fileExists(path): - echo "[sessions] ERROR: ", path, " not found. This file is required to authenticate API requests." + log "ERROR: ", path, " not found. This file is required to authenticate API requests." quit 1 - log "Parsing JSONL account sessions file: ", path + log "parsing JSONL account sessions file: ", path for line in path.lines: sessionPool.add parseSession(line) - log "Successfully added ", sessionPool.len, " valid account sessions." + log "successfully added ", sessionPool.len, " valid account sessions" From 661be438ec0363dd3b153854765081707ab2d369 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Tue, 25 Feb 2025 05:46:18 +0000 Subject: [PATCH 121/309] Support both web and Android sessions --- src/apiutils.nim | 20 +++++++++++++------- src/experimental/parser/session.nim | 27 ++++++++++++++++++--------- src/experimental/types/session.nim | 4 ++++ src/types.nim | 14 ++++++++++++-- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 65dcc29..9f28f7f 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import httpclient, asyncdispatch, options, strutils, uri, times, math, tables +import httpclient, asyncdispatch, options, strformat, strutils, uri, times, math, tables import jsony, packedjson, zippy, oauth1 import types, auth, consts, parserutils, http_pool import experimental/types/common @@ -28,21 +28,27 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = return getOauth1RequestHeader(params)["authorization"] -proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = - let header = getOauthHeader(url, oauthToken, oauthTokenSecret) - +proc genHeaders*(url: string; session: Session): HttpHeaders = result = newHttpHeaders({ "connection": "keep-alive", - "authorization": header, "content-type": "application/json", "x-twitter-active-user": "yes", "authority": "api.x.com", "accept-encoding": "gzip", "accept-language": "en-US,en;q=0.9", "accept": "*/*", - "DNT": "1" + "DNT": "1", }) + case session.kind + of oauth: + result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) + of cookie: + result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" + result["x-twitter-auth-type"] = "OAuth2Session" + result["x-csrf-token"] = session.ct0 + result["cookie"] = &"ct0={session.ct0}; auth_token={session.authToken}" + template fetchImpl(result, fetchBody) {.dirty.} = once: pool = HttpPool() @@ -54,7 +60,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - pool.use(genHeaders($url, session.oauthToken, session.oauthSecret)): + pool.use(genHeaders($url, session)): template getContent = resp = await c.get($url) result = await resp.body diff --git a/src/experimental/parser/session.nim b/src/experimental/parser/session.nim index ee9c93e..0eea787 100644 --- a/src/experimental/parser/session.nim +++ b/src/experimental/parser/session.nim @@ -1,15 +1,24 @@ import std/strutils import jsony import ../types/session -from ../../types import Session +from ../../types import Session, SessionKind proc parseSession*(raw: string): Session = - let - session = raw.fromJson(RawSession) - id = session.oauthToken[0 ..< session.oauthToken.find('-')] + let session = raw.fromJson(RawSession) - result = Session( - id: parseBiggestInt(id), - oauthToken: session.oauthToken, - oauthSecret: session.oauthTokenSecret - ) + case session.kind + of "oauth": + let id = session.oauthToken[0 ..< session.oauthToken.find('-')] + result = Session( + kind: oauth, + id: parseBiggestInt(id), + oauthToken: session.oauthToken, + oauthSecret: session.oauthTokenSecret + ) + of "cookie": + result = Session( + kind: cookie, + id: 999, + ct0: session.ct0, + authToken: session.authToken + ) diff --git a/src/experimental/types/session.nim b/src/experimental/types/session.nim index 4165204..c1588cf 100644 --- a/src/experimental/types/session.nim +++ b/src/experimental/types/session.nim @@ -1,4 +1,8 @@ type RawSession* = object + kind*: string oauthToken*: string oauthTokenSecret*: string + ct0*: string + authToken*: string + diff --git a/src/types.nim b/src/types.nim index 4e565ee..a99c23a 100644 --- a/src/types.nim +++ b/src/types.nim @@ -31,10 +31,20 @@ type remaining*: int reset*: int + SessionKind* = enum + oauth + cookie + Session* = ref object + case kind*: SessionKind + of oauth: + oauthToken*: string + oauthSecret*: string + of cookie: + ct0*: string + authToken*: string + id*: int64 - oauthToken*: string - oauthSecret*: string pending*: int limited*: bool limitedAt*: int From 41fa47bfbf3917e9b3ac4f7b49c89a75a7a2bd44 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Tue, 25 Feb 2025 23:36:02 +0000 Subject: [PATCH 122/309] Revert "Support both web and Android sessions" This reverts commit 661be438ec0363dd3b153854765081707ab2d369. --- src/apiutils.nim | 20 +++++++------------- src/experimental/parser/session.nim | 27 +++++++++------------------ src/experimental/types/session.nim | 4 ---- src/types.nim | 14 ++------------ 4 files changed, 18 insertions(+), 47 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 9f28f7f..65dcc29 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import httpclient, asyncdispatch, options, strformat, strutils, uri, times, math, tables +import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import jsony, packedjson, zippy, oauth1 import types, auth, consts, parserutils, http_pool import experimental/types/common @@ -28,27 +28,21 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = return getOauth1RequestHeader(params)["authorization"] -proc genHeaders*(url: string; session: Session): HttpHeaders = +proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = + let header = getOauthHeader(url, oauthToken, oauthTokenSecret) + result = newHttpHeaders({ "connection": "keep-alive", + "authorization": header, "content-type": "application/json", "x-twitter-active-user": "yes", "authority": "api.x.com", "accept-encoding": "gzip", "accept-language": "en-US,en;q=0.9", "accept": "*/*", - "DNT": "1", + "DNT": "1" }) - case session.kind - of oauth: - result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) - of cookie: - result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" - result["x-twitter-auth-type"] = "OAuth2Session" - result["x-csrf-token"] = session.ct0 - result["cookie"] = &"ct0={session.ct0}; auth_token={session.authToken}" - template fetchImpl(result, fetchBody) {.dirty.} = once: pool = HttpPool() @@ -60,7 +54,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - pool.use(genHeaders($url, session)): + pool.use(genHeaders($url, session.oauthToken, session.oauthSecret)): template getContent = resp = await c.get($url) result = await resp.body diff --git a/src/experimental/parser/session.nim b/src/experimental/parser/session.nim index 0eea787..ee9c93e 100644 --- a/src/experimental/parser/session.nim +++ b/src/experimental/parser/session.nim @@ -1,24 +1,15 @@ import std/strutils import jsony import ../types/session -from ../../types import Session, SessionKind +from ../../types import Session proc parseSession*(raw: string): Session = - let session = raw.fromJson(RawSession) + let + session = raw.fromJson(RawSession) + id = session.oauthToken[0 ..< session.oauthToken.find('-')] - case session.kind - of "oauth": - let id = session.oauthToken[0 ..< session.oauthToken.find('-')] - result = Session( - kind: oauth, - id: parseBiggestInt(id), - oauthToken: session.oauthToken, - oauthSecret: session.oauthTokenSecret - ) - of "cookie": - result = Session( - kind: cookie, - id: 999, - ct0: session.ct0, - authToken: session.authToken - ) + result = Session( + id: parseBiggestInt(id), + oauthToken: session.oauthToken, + oauthSecret: session.oauthTokenSecret + ) diff --git a/src/experimental/types/session.nim b/src/experimental/types/session.nim index c1588cf..4165204 100644 --- a/src/experimental/types/session.nim +++ b/src/experimental/types/session.nim @@ -1,8 +1,4 @@ type RawSession* = object - kind*: string oauthToken*: string oauthTokenSecret*: string - ct0*: string - authToken*: string - diff --git a/src/types.nim b/src/types.nim index a99c23a..4e565ee 100644 --- a/src/types.nim +++ b/src/types.nim @@ -31,20 +31,10 @@ type remaining*: int reset*: int - SessionKind* = enum - oauth - cookie - Session* = ref object - case kind*: SessionKind - of oauth: - oauthToken*: string - oauthSecret*: string - of cookie: - ct0*: string - authToken*: string - id*: int64 + oauthToken*: string + oauthSecret*: string pending*: int limited*: bool limitedAt*: int From 83b0f8b55ae7bfb8a19a0bf14de52f30d06b8db6 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 5 Apr 2025 15:57:14 +0100 Subject: [PATCH 123/309] Retry limited accounts after an hour --- src/auth.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth.nim b/src/auth.nim index 0ff40f8..81f248a 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -6,7 +6,7 @@ import experimental/parser/session # max requests at a time per session to avoid race conditions const maxConcurrentReqs = 2 - dayInSeconds = 24 * 60 * 60 + hourInSeconds = 60 * 60 apiMaxReqs: Table[Api, int] = { Api.search: 50, Api.tweetDetail: 500, @@ -127,7 +127,7 @@ proc isLimited(session: Session; api: Api): bool = return true if session.limited and api != Api.userTweets: - if (epochTime().int - session.limitedAt) > dayInSeconds: + if (epochTime().int - session.limitedAt) > hourInSeconds: session.limited = false log "resetting limit: ", session.id return false From 94c83f38114abaef10c36903fbcd59d78db7a578 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Tue, 15 Apr 2025 02:06:00 +0100 Subject: [PATCH 124/309] Hide ads/promoted tweets Fixes #1234 --- src/parser.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 6e0be73..01f0341 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -336,7 +336,7 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = let cursor = t{"item", "content", "value"} result.thread.cursor = cursor.getStr result.thread.hasMore = true - elif "tweet" in entryId: + elif "tweet" in entryId and "promoted" notin entryId: let isLegacy = t{"item"}.hasKey("itemContent") (contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results") @@ -381,7 +381,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati let (thread, self) = parseGraphThread(e) if self: result.after = thread - else: + elif thread.content.len > 0: result.replies.content.add thread elif entryId.startsWith("tombstone"): let id = entryId.getId() From e40c61a6ae76431c570951cc4925f38523b00a82 Mon Sep 17 00:00:00 2001 From: Gabriel Simmer <github@gmem.ca> Date: Thu, 1 May 2025 12:39:05 +0100 Subject: [PATCH 125/309] Find TimelineAddEntries in tweets response (#1251) See https://github.com/zedeus/nitter/issues/1250. Sometimes the API gives us more results and the tweets are no longer at index 0. --- src/parser.nim | 62 ++++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 01f0341..5fdfebd 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -364,39 +364,41 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati if instructions.len == 0: return - for e in instructions[0]{"entries"}: - let entryId = e{"entryId"}.getStr - if entryId.startsWith("tweet"): - with tweetResult, e{"content", contentKey, resultKey, "result"}: - let tweet = parseGraphTweet(tweetResult, not v2) + for i in instructions: + if i{"__typename"}.getStr == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet"): + with tweetResult, e{"content", contentKey, resultKey, "result"}: + let tweet = parseGraphTweet(tweetResult, not v2) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) - if $tweet.id == tweetId: - result.tweet = tweet - else: - result.before.content.add tweet - elif entryId.startsWith("conversationthread"): - let (thread, self) = parseGraphThread(e) - if self: - result.after = thread - elif thread.content.len > 0: - result.replies.content.add thread - elif entryId.startsWith("tombstone"): - let id = entryId.getId() - let tweet = Tweet( - id: parseBiggestInt(id), - available: false, - text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone - ) + if $tweet.id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet + elif entryId.startsWith("conversationthread"): + let (thread, self) = parseGraphThread(e) + if self: + result.after = thread + elif thread.content.len > 0: + result.replies.content.add thread + elif entryId.startsWith("tombstone"): + let id = entryId.getId() + let tweet = Tweet( + id: parseBiggestInt(id), + available: false, + text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone + ) - if id == tweetId: - result.tweet = tweet - else: - result.before.content.add tweet - elif entryId.startsWith("cursor-bottom"): - result.replies.bottom = e{"content", contentKey, "value"}.getStr + if id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet + elif entryId.startsWith("cursor-bottom"): + result.replies.bottom = e{"content", contentKey, "value"}.getStr proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) From 662ae90e2246c8a01c811f68750b7e5033e0fa69 Mon Sep 17 00:00:00 2001 From: 0xbarchitect <tbng84@gmail.com> Date: Sun, 12 Oct 2025 14:07:37 +0700 Subject: [PATCH 126/309] Bypass Cloudflare 403 error using cloudscraper (#1291) * Bypass Cloudflare 403 error using cloudscraper * add docs --- tools/get_session.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tools/get_session.py b/tools/get_session.py index 9ff704d..9f91483 100644 --- a/tools/get_session.py +++ b/tools/get_session.py @@ -3,9 +3,10 @@ import requests import json import sys import pyotp +import cloudscraper -# NOTE: pyotp and requests are dependencies -# > pip install pyotp requests +# NOTE: pyotp, requests and cloudscraper are dependencies +# > pip install pyotp requests cloudscraper TW_CONSUMER_KEY = '3nVuSoBZnx6U4vzUxf5w' TW_CONSUMER_SECRET = 'Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys' @@ -41,10 +42,10 @@ def auth(username, password, otp_secret): "X-Twitter-Client-DeviceID": "" } - session = requests.Session() - session.headers = twitter_header + scraper = cloudscraper.create_scraper() + scraper.headers = twitter_header - task1 = session.post( + task1 = scraper.post( 'https://api.twitter.com/1.1/onboarding/task.json', params={ 'flow_name': 'login', @@ -71,9 +72,9 @@ def auth(username, password, otp_secret): } ) - session.headers['att'] = task1.headers.get('att') + scraper.headers['att'] = task1.headers.get('att') - task2 = session.post( + task2 = scraper.post( 'https://api.twitter.com/1.1/onboarding/task.json', json={ "flow_token": task1.json().get('flow_token'), @@ -88,7 +89,7 @@ def auth(username, password, otp_secret): } ) - task3 = session.post( + task3 = scraper.post( 'https://api.twitter.com/1.1/onboarding/task.json', json={ "flow_token": task2.json().get('flow_token'), @@ -109,7 +110,7 @@ def auth(username, password, otp_secret): response_text = t3_subtask["enter_text"]["hint_text"] totp = pyotp.TOTP(otp_secret) generated_code = totp.now() - task4resp = session.post( + task4resp = scraper.post( "https://api.twitter.com/1.1/onboarding/task.json", json={ "flow_token": task3.json().get("flow_token"), From 32b04a772bba866f51d11eed6708d0e41098d926 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 15 Nov 2025 06:50:48 +0100 Subject: [PATCH 127/309] Include pinned tweets in RSS Fixes #1262 --- src/views/rss.nimf | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 036a7b9..819f99c 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -28,6 +28,24 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #end proc # +#proc getTweetsWithPinned(profile: Profile): seq[Tweets] = +#result = profile.tweets.content +#if profile.pinned.isSome and result.len > 0: +# let pinnedTweet = profile.pinned.get +# var inserted = false +# for threadIdx in 0 ..< result.len: +# if not inserted: +# for tweetIdx in 0 ..< result[threadIdx].len: +# if result[threadIdx][tweetIdx].id < pinnedTweet.id: +# result[threadIdx].insert(pinnedTweet, tweetIdx) +# inserted = true +# end if +# end for +# end if +# end for +#end if +#end proc +# #proc renderRssTweet(tweet: Tweet; cfg: Config): string = #let tweet = tweet.retweet.get(tweet) #let urlPrefix = getUrlPrefix(cfg) @@ -106,8 +124,9 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} <width>128</width> <height>128</height> </image> -#if profile.tweets.content.len > 0: -${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)} +#let tweetsList = getTweetsWithPinned(profile) +#if tweetsList.len > 0: +${renderRssTweets(tweetsList, cfg, userId=profile.user.id)} #end if </channel> </rss> From 9e956150217dd6958fa8cf0f5e916bb080518642 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 15 Nov 2025 07:01:03 +0100 Subject: [PATCH 128/309] Remove unused skipPinned parameter --- src/routes/rss.nim | 2 +- src/routes/timeline.nim | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 447f4ad..b0e781d 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -23,7 +23,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. names = getNames(name) if names.len == 1: - profile = await fetchProfile(after, query, skipRail=true, skipPinned=true) + profile = await fetchProfile(after, query, skipRail=true) else: var q = query q.fromUser = names diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 8cd1fd7..7a10e91 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -27,8 +27,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = else: body -proc fetchProfile*(after: string; query: Query; skipRail=false; - skipPinned=false): Future[Profile] {.async.} = +proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] {.async.} = let name = query.fromUser[0] userId = await getUserId(name) @@ -71,7 +70,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; html = renderTweetSearch(timeline, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) - var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins) + var profile = await fetchProfile(after, query) template u: untyped = profile.user if u.suspended: From f89d2329d28c802e88747f7beb28db3b7e90cc5d Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 15 Nov 2025 22:59:35 +0100 Subject: [PATCH 129/309] Add cookie-based authentication support Fixes #1303 --- src/apiutils.nim | 28 ++++++++++++++++++++------- src/experimental/parser/session.nim | 30 ++++++++++++++++++++--------- src/experimental/types/session.nim | 3 +++ src/types.nim | 13 +++++++++++-- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 65dcc29..875fcb0 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -28,12 +28,12 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = return getOauth1RequestHeader(params)["authorization"] -proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = - let header = getOauthHeader(url, oauthToken, oauthTokenSecret) +proc getCookieHeader(authToken, ct0: string): string = + "auth_token=" & authToken & "; ct0=" & ct0 +proc genHeaders*(session: Session, url: string): HttpHeaders = result = newHttpHeaders({ "connection": "keep-alive", - "authorization": header, "content-type": "application/json", "x-twitter-active-user": "yes", "authority": "api.x.com", @@ -43,18 +43,32 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = "DNT": "1" }) + case session.kind + of SessionKind.oauth: + result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) + of SessionKind.cookie: + result["cookie"] = getCookieHeader(session.authToken, session.ct0) + result["x-csrf-token"] = session.ct0 + result["x-twitter-auth-type"] = "OAuth2Session" + template fetchImpl(result, fetchBody) {.dirty.} = once: pool = HttpPool() var session = await getSession(api) - if session.oauthToken.len == 0: - echo "[sessions] Empty oauth token, session: ", session.id - raise rateLimitError() + case session.kind + of SessionKind.oauth: + if session.oauthToken.len == 0: + echo "[sessions] Empty oauth token, session: ", session.id + raise rateLimitError() + of SessionKind.cookie: + if session.authToken.len == 0 or session.ct0.len == 0: + echo "[sessions] Empty cookie credentials, session: ", session.id + raise rateLimitError() try: var resp: AsyncResponse - pool.use(genHeaders($url, session.oauthToken, session.oauthSecret)): + pool.use(genHeaders(session, $url)): template getContent = resp = await c.get($url) result = await resp.body diff --git a/src/experimental/parser/session.nim b/src/experimental/parser/session.nim index ee9c93e..db72e85 100644 --- a/src/experimental/parser/session.nim +++ b/src/experimental/parser/session.nim @@ -1,15 +1,27 @@ import std/strutils import jsony import ../types/session -from ../../types import Session +from ../../types import Session, SessionKind proc parseSession*(raw: string): Session = - let - session = raw.fromJson(RawSession) - id = session.oauthToken[0 ..< session.oauthToken.find('-')] + let session = raw.fromJson(RawSession) + let kind = if session.kind == "": "oauth" else: session.kind - result = Session( - id: parseBiggestInt(id), - oauthToken: session.oauthToken, - oauthSecret: session.oauthTokenSecret - ) + case kind + of "oauth": + let id = session.oauthToken[0 ..< session.oauthToken.find('-')] + result = Session( + kind: SessionKind.oauth, + id: parseBiggestInt(id), + oauthToken: session.oauthToken, + oauthSecret: session.oauthTokenSecret + ) + of "cookie": + result = Session( + kind: SessionKind.cookie, + id: 999, + authToken: session.authToken, + ct0: session.ct0 + ) + else: + raise newException(ValueError, "Unknown session kind: " & kind) diff --git a/src/experimental/types/session.nim b/src/experimental/types/session.nim index 4165204..c0cb58f 100644 --- a/src/experimental/types/session.nim +++ b/src/experimental/types/session.nim @@ -1,4 +1,7 @@ type RawSession* = object + kind*: string oauthToken*: string oauthTokenSecret*: string + authToken*: string + ct0*: string diff --git a/src/types.nim b/src/types.nim index 4e565ee..5755f65 100644 --- a/src/types.nim +++ b/src/types.nim @@ -31,14 +31,23 @@ type remaining*: int reset*: int + SessionKind* = enum + oauth + cookie + Session* = ref object id*: int64 - oauthToken*: string - oauthSecret*: string pending*: int limited*: bool limitedAt*: int apis*: Table[Api, RateLimit] + case kind*: SessionKind + of oauth: + oauthToken*: string + oauthSecret*: string + of cookie: + authToken*: string + ct0*: string Error* = enum null = 0 From 3768762fca4a89ab7e2aaa1bcf934ec438ec9041 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 16 Nov 2025 05:02:57 +0100 Subject: [PATCH 130/309] Add script for generating cookies --- tools/get_web_session.py | 143 +++++++++++++++++++++++++++++++++++++++ tools/requirements.txt | 2 + 2 files changed, 145 insertions(+) create mode 100644 tools/get_web_session.py create mode 100644 tools/requirements.txt diff --git a/tools/get_web_session.py b/tools/get_web_session.py new file mode 100644 index 0000000..36a40b0 --- /dev/null +++ b/tools/get_web_session.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Authenticates with X.com/Twitter and extracts session cookies for use with Nitter. +Handles 2FA, extracts user info, and outputs clean JSON for sessions.jsonl. + +Requirements: + pip install -r tools/requirements.txt + +Usage: + python3 tools/get_web_session.py <username> <password> [totp_seed] [--append sessions.jsonl] [--headless] + +Examples: + # Output to terminal + python3 tools/get_web_session.py myusername mypassword TOTP_BASE32_SECRET + + # Append to sessions.jsonl + python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --append sessions.jsonl + + # Headless mode (may increase detection risk) + python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --headless + +Output: + {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} +""" + +import sys +import json +import asyncio +import pyotp +import nodriver as uc + + +async def login_and_get_cookies(username, password, totp_seed=None, headless=False): + """Authenticate with X.com and extract session cookies""" + # Note: headless mode may increase detection risk from bot-detection systems + browser = await uc.start(headless=headless) + tab = await browser.get('https://x.com/i/flow/login') + + try: + # Enter username + print('[*] Entering username...', file=sys.stderr) + username_input = await tab.find('input[autocomplete="username"]', timeout=10) + await username_input.send_keys(username + '\n') + await asyncio.sleep(1) + + # Enter password + print('[*] Entering password...', file=sys.stderr) + password_input = await tab.find('input[autocomplete="current-password"]', timeout=15) + await password_input.send_keys(password + '\n') + await asyncio.sleep(2) + + # Handle 2FA if needed + page_content = await tab.get_content() + if 'verification code' in page_content or 'Enter code' in page_content: + if not totp_seed: + raise Exception('2FA required but no TOTP seed provided') + + print('[*] 2FA detected, entering code...', file=sys.stderr) + totp_code = pyotp.TOTP(totp_seed).now() + code_input = await tab.select('input[type="text"]') + await code_input.send_keys(totp_code + '\n') + await asyncio.sleep(3) + + # Get cookies + print('[*] Retrieving cookies...', file=sys.stderr) + for _ in range(20): # 20 second timeout + cookies = await browser.cookies.get_all() + cookies_dict = {cookie.name: cookie.value for cookie in cookies} + + if 'auth_token' in cookies_dict and 'ct0' in cookies_dict: + print('[*] Found both cookies', file=sys.stderr) + + # Extract ID from twid cookie (may be URL-encoded) + user_id = None + if 'twid' in cookies_dict: + twid = cookies_dict['twid'] + # Try to extract the ID from twid (format: u%3D<id> or u=<id>) + if 'u%3D' in twid: + user_id = twid.split('u%3D')[1].split('&')[0] + elif 'u=' in twid: + user_id = twid.split('u=')[1].split('&')[0] + + cookies_dict['username'] = username + if user_id: + cookies_dict['id'] = user_id + + return cookies_dict + + await asyncio.sleep(1) + + raise Exception('Timeout waiting for cookies') + + finally: + browser.stop() + + +async def main(): + if len(sys.argv) < 3: + print('Usage: python3 twitter-auth.py username password [totp_seed] [--append sessions.jsonl] [--headless]') + sys.exit(1) + + username = sys.argv[1] + password = sys.argv[2] + totp_seed = None + append_file = None + headless = False + + # Parse optional arguments + for i, arg in enumerate(sys.argv[3:], 3): + if arg == '--append' and i + 1 < len(sys.argv): + append_file = sys.argv[i + 1] + elif arg == '--headless': + headless = True + elif not arg.startswith('--'): + totp_seed = arg + + try: + cookies = await login_and_get_cookies(username, password, totp_seed, headless) + session = { + 'kind': 'cookie', + 'username': cookies['username'], + 'id': cookies.get('id'), + 'auth_token': cookies['auth_token'], + 'ct0': cookies['ct0'] + } + output = json.dumps(session) + + if append_file: + with open(append_file, 'a') as f: + f.write(output + '\n') + print(f'✓ Session appended to {append_file}', file=sys.stderr) + else: + print(output) + + os._exit(0) + + except Exception as error: + print(f'[!] Error: {error}', file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/tools/requirements.txt b/tools/requirements.txt new file mode 100644 index 0000000..4827475 --- /dev/null +++ b/tools/requirements.txt @@ -0,0 +1,2 @@ +nodriver>=0.48.0 +pyotp From 6fe850b2c62dc664ee49645e8aad48bc6db9328e Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 16 Nov 2025 05:03:01 +0100 Subject: [PATCH 131/309] Add optional cookie session fields --- src/experimental/parser/session.nim | 3 ++- src/experimental/types/session.nim | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/experimental/parser/session.nim b/src/experimental/parser/session.nim index db72e85..bb31d83 100644 --- a/src/experimental/parser/session.nim +++ b/src/experimental/parser/session.nim @@ -17,9 +17,10 @@ proc parseSession*(raw: string): Session = oauthSecret: session.oauthTokenSecret ) of "cookie": + let id = if session.id.len > 0: parseBiggestInt(session.id) else: 0 result = Session( kind: SessionKind.cookie, - id: 999, + id: id, authToken: session.authToken, ct0: session.ct0 ) diff --git a/src/experimental/types/session.nim b/src/experimental/types/session.nim index c0cb58f..dd6be22 100644 --- a/src/experimental/types/session.nim +++ b/src/experimental/types/session.nim @@ -1,6 +1,8 @@ type RawSession* = object kind*: string + username*: string + id*: string oauthToken*: string oauthTokenSecret*: string authToken*: string From 4fc7b873c44c0dcd358bb969965345712610b8c5 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 16 Nov 2025 05:22:45 +0100 Subject: [PATCH 132/309] Use dynamic rate limits from API responses --- src/apiutils.nim | 4 +++- src/auth.nim | 13 +++++++------ src/types.nim | 1 + 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 875fcb0..f6fa9f1 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -7,6 +7,7 @@ import experimental/types/common const rlRemaining = "x-rate-limit-remaining" rlReset = "x-rate-limit-reset" + rlLimit = "x-rate-limit-limit" errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} var pool: HttpPool @@ -83,7 +84,8 @@ template fetchImpl(result, fetchBody) {.dirty.} = let remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) - session.setRateLimit(api, remaining, reset) + limit = parseInt(resp.headers[rlLimit]) + session.setRateLimit(api, remaining, reset, limit) if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": diff --git a/src/auth.nim b/src/auth.nim index 81f248a..9f9fe8a 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -57,7 +57,8 @@ proc getSessionPoolHealth*(): JsonNode = for api in session.apis.keys: let apiStatus = session.apis[api] - reqs = apiMaxReqs[api] - apiStatus.remaining + limit = if apiStatus.limit > 0: apiStatus.limit else: apiMaxReqs.getOrDefault(api, 0) + reqs = limit - apiStatus.remaining # no requests made with this session and endpoint since the limit reset if apiStatus.reset < now: @@ -172,17 +173,17 @@ proc setLimited*(session: Session; api: Api) = session.limitedAt = epochTime().int log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", id: ", session.id -proc setRateLimit*(session: Session; api: Api; remaining, reset: int) = +proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = # avoid undefined behavior in race conditions if api in session.apis: - let limit = session.apis[api] - if limit.reset >= reset and limit.remaining < remaining: + let rateLimit = session.apis[api] + if rateLimit.reset >= reset and rateLimit.remaining < remaining: return - if limit.reset == reset and limit.remaining >= remaining: + if rateLimit.reset == reset and rateLimit.remaining >= remaining: session.apis[api].remaining = remaining return - session.apis[api] = RateLimit(remaining: remaining, reset: reset) + session.apis[api] = RateLimit(limit: limit, remaining: remaining, reset: reset) proc initSessionPool*(cfg: Config; path: string) = enableLogging = cfg.enableDebug diff --git a/src/types.nim b/src/types.nim index 5755f65..a138dae 100644 --- a/src/types.nim +++ b/src/types.nim @@ -28,6 +28,7 @@ type userMedia RateLimit* = object + limit*: int remaining*: int reset*: int From 5aa0b65fea43dcdabe9d191bb67df3e187796bf4 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 16 Nov 2025 05:24:23 +0100 Subject: [PATCH 133/309] Add bearer token for cookie-based auth --- src/apiutils.nim | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index f6fa9f1..0745ee6 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -37,20 +37,23 @@ proc genHeaders*(session: Session, url: string): HttpHeaders = "connection": "keep-alive", "content-type": "application/json", "x-twitter-active-user": "yes", + "x-twitter-client-language": "en", "authority": "api.x.com", "accept-encoding": "gzip", "accept-language": "en-US,en;q=0.9", "accept": "*/*", - "DNT": "1" + "DNT": "1", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" }) case session.kind of SessionKind.oauth: result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: - result["cookie"] = getCookieHeader(session.authToken, session.ct0) - result["x-csrf-token"] = session.ct0 + result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" result["x-twitter-auth-type"] = "OAuth2Session" + result["x-csrf-token"] = session.ct0 + result["cookie"] = getCookieHeader(session.authToken, session.ct0) template fetchImpl(result, fetchBody) {.dirty.} = once: From bf36fc471b7335d60168f1f9a08be9288f928ad9 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 16 Nov 2025 06:04:46 +0100 Subject: [PATCH 134/309] Update tests --- tests/test_card.py | 10 +++++----- tests/test_quote.py | 6 ------ tests/test_tweet.py | 8 -------- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/tests/test_card.py b/tests/test_card.py index 05b55f6..504c079 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -26,14 +26,14 @@ no_thumb = [ 'lnkd.in'], ['Thom_Wolf/status/1122466524860702729', - 'GitHub - NVIDIA/Megatron-LM: Ongoing research training transformer models at scale', - 'Ongoing research training transformer models at scale - NVIDIA/Megatron-LM', + 'GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in', + '', 'github.com'], ['brent_p/status/1088857328680488961', - 'Hts Nim Sugar', - 'hts-nim is a library that allows one to use htslib via the nim programming language. Nim is a garbage-collected language that compiles to C and often has similar performance. I have become very...', - 'brentp.github.io'], + 'GitHub - brentp/hts-nim: nim wrapper for htslib for parsing genomics data files', + '', + 'github.com'], ['voidtarget/status/1133028231672582145', 'sinkingsugar/nimqt-example', diff --git a/tests/test_quote.py b/tests/test_quote.py index 4921c21..53ad79f 100644 --- a/tests/test_quote.py +++ b/tests/test_quote.py @@ -2,12 +2,6 @@ from base import BaseTestCase, Quote, Conversation from parameterized import parameterized text = [ - ['elonmusk/status/1138136540096319488', - 'TREV PAGE', '@Model3Owners', - """As of March 58.4% of new car sales in Norway are electric. - -What are we doing wrong? reuters.com/article/us-norwa…"""], - ['nim_lang/status/1491461266849808397#m', 'Nim', '@nim_lang', """What's better than Nim 1.6.0? diff --git a/tests/test_tweet.py b/tests/test_tweet.py index 839e6c5..bf9e267 100644 --- a/tests/test_tweet.py +++ b/tests/test_tweet.py @@ -28,14 +28,6 @@ invalid = [ ] multiline = [ - [400897186990284800, 'mobile_test_3', - """ -♔ - KEEP - CALM - AND -CLICHÉ - ON"""], [1718660434457239868, 'WebDesignMuseum', """ Happy 32nd Birthday HTML tags! From 55d4469401479f7feed166b745821f7abca8fb9d Mon Sep 17 00:00:00 2001 From: 0xCathiefish <72328723+0xcathiefish@users.noreply.github.com> Date: Mon, 17 Nov 2025 04:18:38 +0800 Subject: [PATCH 135/309] fix: correct argument parsing for --append flag in get_web_session.py (#1305) * fix: correct argument parsing for --append flag * fix: strip quotes from extracted user_id in twid cookie --- tools/get_web_session.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tools/get_web_session.py b/tools/get_web_session.py index 36a40b0..502c7f4 100644 --- a/tools/get_web_session.py +++ b/tools/get_web_session.py @@ -28,6 +28,7 @@ import json import asyncio import pyotp import nodriver as uc +import os async def login_and_get_cookies(username, password, totp_seed=None, headless=False): @@ -76,9 +77,9 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal twid = cookies_dict['twid'] # Try to extract the ID from twid (format: u%3D<id> or u=<id>) if 'u%3D' in twid: - user_id = twid.split('u%3D')[1].split('&')[0] + user_id = twid.split('u%3D')[1].split('&')[0].strip('"') elif 'u=' in twid: - user_id = twid.split('u=')[1].split('&')[0] + user_id = twid.split('u=')[1].split('&')[0].strip('"') cookies_dict['username'] = username if user_id: @@ -106,13 +107,27 @@ async def main(): headless = False # Parse optional arguments - for i, arg in enumerate(sys.argv[3:], 3): - if arg == '--append' and i + 1 < len(sys.argv): - append_file = sys.argv[i + 1] + i = 3 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == '--append': + if i + 1 < len(sys.argv): + append_file = sys.argv[i + 1] + i += 2 # Skip '--append' and filename + else: + print('[!] Error: --append requires a filename', file=sys.stderr) + sys.exit(1) elif arg == '--headless': headless = True + i += 1 elif not arg.startswith('--'): - totp_seed = arg + if totp_seed is None: + totp_seed = arg + i += 1 + else: + # Unkown args + print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr) + i += 1 try: cookies = await login_and_get_cookies(username, password, totp_seed, headless) From 68fc7b71c86d34f43cbfb9147c22babe8604b305 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 16 Nov 2025 23:21:15 +0100 Subject: [PATCH 136/309] Fix media support for cookie sessions Fixes #1304 --- src/api.nim | 38 +++++++++++------ src/apiutils.nim | 44 +++++++++++++------ src/consts.nim | 58 ++++++++++++++++--------- src/parser.nim | 109 +++++++++++++++++++++++++++++++++-------------- src/types.nim | 6 ++- 5 files changed, 174 insertions(+), 81 deletions(-) diff --git a/src/api.nim b/src/api.nim index 479cb3d..c0efa58 100644 --- a/src/api.nim +++ b/src/api.nim @@ -4,6 +4,15 @@ import packedjson import types, query, formatters, consts, apiutils, parser import experimental/parser as newParser +proc mediaUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = userMediaVariables % [id, cursor] + oauthVariables = userTweetsVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphUserMedia ? {"variables": cookieVariables, "features": gqlFeatures}, + oauthUrl: graphUserMediaV2 ? {"variables": oauthVariables, "features": gqlFeatures} + ) + proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return let @@ -26,12 +35,14 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" variables = userTweetsVariables % [id, cursor] params = {"variables": variables, "features": gqlFeatures} - (url, apiId) = case kind - of TimelineKind.tweets: (graphUserTweets, Api.userTweets) - of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies) - of TimelineKind.media: (graphUserMedia, Api.userMedia) - js = await fetch(url ? params, apiId) - result = parseGraphTimeline(js, "user", after) + js = case kind + of TimelineKind.tweets: + await fetch(graphUserTweets ? params, Api.userTweets) + of TimelineKind.replies: + await fetch(graphUserTweetsAndReplies ? params, Api.userTweetsAndReplies) + of TimelineKind.media: + await fetch(mediaUrl(id, cursor), Api.userMedia) + result = parseGraphTimeline(js, after) proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return @@ -40,19 +51,21 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = variables = listTweetsVariables % [id, cursor] params = {"variables": variables, "features": gqlFeatures} js = await fetch(graphListTweets ? params, Api.listTweets) - result = parseGraphTimeline(js, "list", after).tweets + result = parseGraphTimeline(js, after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list} params = {"variables": $variables, "features": gqlFeatures} - result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug)) + url = graphListBySlug ? params + result = parseGraphList(await fetch(url, Api.listBySlug)) proc getGraphList*(id: string): Future[List] {.async.} = let variables = """{"listId": "$1"}""" % id params = {"variables": variables, "features": gqlFeatures} - result = parseGraphList(await fetch(graphListById ? params, Api.list)) + url = graphListById ? params + result = parseGraphList(await fetch(url, Api.list)) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = if list.id.len == 0: return @@ -138,11 +151,8 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return - let - variables = userTweetsVariables % [id, ""] - params = {"variables": variables, "features": gqlFeatures} - url = graphUserMedia ? params - result = parseGraphPhotoRail(await fetch(url, Api.userMedia)) + let js = await fetch(mediaUrl(id, ""), Api.userMedia) + result = parseGraphPhotoRail(js) proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) diff --git a/src/apiutils.nim b/src/apiutils.nim index 0745ee6..ae459fa 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -55,21 +55,22 @@ proc genHeaders*(session: Session, url: string): HttpHeaders = result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) +proc getAndValidateSession*(api: Api): Future[Session] {.async.} = + result = await getSession(api) + case result.kind + of SessionKind.oauth: + if result.oauthToken.len == 0: + echo "[sessions] Empty oauth token, session: ", result.id + raise rateLimitError() + of SessionKind.cookie: + if result.authToken.len == 0 or result.ct0.len == 0: + echo "[sessions] Empty cookie credentials, session: ", result.id + raise rateLimitError() + template fetchImpl(result, fetchBody) {.dirty.} = once: pool = HttpPool() - var session = await getSession(api) - case session.kind - of SessionKind.oauth: - if session.oauthToken.len == 0: - echo "[sessions] Empty oauth token, session: ", session.id - raise rateLimitError() - of SessionKind.cookie: - if session.authToken.len == 0 or session.ct0.len == 0: - echo "[sessions] Empty cookie credentials, session: ", session.id - raise rateLimitError() - try: var resp: AsyncResponse pool.use(genHeaders(session, $url)): @@ -136,9 +137,17 @@ template retry(bod) = echo "[sessions] Rate limited, retrying ", api, " request..." bod -proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = +proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = retry: - var body: string + var + body: string + session = await getAndValidateSession(api) + + when url is SessionAwareUrl: + let url = case session.kind + of SessionKind.oauth: url.oauthUrl + of SessionKind.cookie: url.cookieUrl + fetchImpl body: if body.startsWith('{') or body.startsWith('['): result = parseJson(body) @@ -153,8 +162,15 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = invalidate(session) raise rateLimitError() -proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = +proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = retry: + var session = await getAndValidateSession(api) + + when url is SessionAwareUrl: + let url = case session.kind + of SessionKind.oauth: url.oauthUrl + of SessionKind.cookie: url.cookieUrl + fetchImpl result: if not (result.startsWith('{') or result.startsWith('[')): echo resp.status, ": ", result, " --- url: ", url diff --git a/src/consts.nim b/src/consts.nim index 7c67706..c8ae8d2 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -11,7 +11,8 @@ const graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" graphUserTweets* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2" graphUserTweetsAndReplies* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" - graphUserMedia* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" + graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" + graphUserMediaV2* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2" graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" graphSearchTimeline* = gql / "KI9jCXUx3Ymt-hDKLOZb9Q/SearchTimeline" @@ -25,28 +26,28 @@ const "blue_business_profile_image_shape_enabled": false, "creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true, - "freedom_of_speech_not_reach_fetch_enabled": false, - "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, + "freedom_of_speech_not_reach_fetch_enabled": true, + "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, "hidden_profile_likes_enabled": false, "highlights_tweets_tab_ui_enabled": false, "interactive_text_enabled": false, "longform_notetweets_consumption_enabled": true, - "longform_notetweets_inline_media_enabled": false, + "longform_notetweets_inline_media_enabled": true, "longform_notetweets_richtext_consumption_enabled": true, - "longform_notetweets_rich_text_read_enabled": false, - "responsive_web_edit_tweet_api_enabled": false, + "longform_notetweets_rich_text_read_enabled": true, + "responsive_web_edit_tweet_api_enabled": true, "responsive_web_enhance_cards_enabled": false, "responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, - "responsive_web_graphql_timeline_navigation_enabled": false, + "responsive_web_graphql_timeline_navigation_enabled": true, "responsive_web_media_download_video_enabled": false, "responsive_web_text_conversations_enabled": false, - "responsive_web_twitter_article_tweet_consumption_enabled": false, + "responsive_web_twitter_article_tweet_consumption_enabled": true, "responsive_web_twitter_blue_verified_badge_is_enabled": true, "rweb_lists_timeline_redesign_enabled": true, "spaces_2022_h2_clipping": true, "spaces_2022_h2_spaces_communities": true, - "standardized_nudges_misinfo": false, + "standardized_nudges_misinfo": true, "subscriptions_verification_info_enabled": true, "subscriptions_verification_info_reason_enabled": true, "subscriptions_verification_info_verified_since_enabled": true, @@ -55,28 +56,34 @@ const "super_follow_tweet_api_enabled": false, "super_follow_user_api_enabled": false, "tweet_awards_web_tipping_enabled": false, - "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, "tweetypie_unmention_optimization_enabled": false, "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, "verified_phone_label_enabled": false, "vibe_api_enabled": false, - "view_counts_everywhere_api_enabled": false, + "view_counts_everywhere_api_enabled": true, "premium_content_api_read_enabled": false, - "communities_web_enable_tweet_community_results_fetch": false, - "responsive_web_jetfuel_frame": false, + "communities_web_enable_tweet_community_results_fetch": true, + "responsive_web_jetfuel_frame": true, "responsive_web_grok_analyze_button_fetch_trends_enabled": false, - "responsive_web_grok_image_annotation_enabled": false, - "rweb_tipjar_consumption_enabled": false, - "profile_label_improvements_pcf_label_in_post_enabled": false, + "responsive_web_grok_image_annotation_enabled": true, + "responsive_web_grok_imagine_annotation_enabled": true, + "rweb_tipjar_consumption_enabled": true, + "profile_label_improvements_pcf_label_in_post_enabled": true, "creator_subscriptions_quote_tweet_preview_enabled": false, - "c9s_tweet_anatomy_moderator_badge_enabled": false, - "responsive_web_grok_analyze_post_followups_enabled": false, + "c9s_tweet_anatomy_moderator_badge_enabled": true, + "responsive_web_grok_analyze_post_followups_enabled": true, "rweb_video_timestamps_enabled": false, - "responsive_web_grok_share_attachment_enabled": false, - "articles_preview_enabled": false, + "responsive_web_grok_share_attachment_enabled": true, + "articles_preview_enabled": true, "immersive_video_status_linkable_timestamps": false, "articles_api_enabled": false, - "responsive_web_grok_analysis_button_from_backend": false + "responsive_web_grok_analysis_button_from_backend": true, + "rweb_video_screen_enabled": false, + "payments_enabled": false, + "responsive_web_profile_redirect_enabled": false, + "responsive_web_grok_show_grok_translated_post": false, + "responsive_web_grok_community_note_auto_translation_is_enabled": false }""".replace(" ", "").replace("\n", "") tweetVariables* = """{ @@ -110,3 +117,12 @@ const "rest_id": "$1", $2 "count": 20 }""" + + userMediaVariables* = """{ + "userId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withClientEventToken": false, + "withBirdwatchNotes": false, + "withVoice": true +}""".replace(" ", "").replace("\n", "") diff --git a/src/parser.nim b/src/parser.nim index 5fdfebd..af81469 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -6,6 +6,22 @@ import experimental/parser/unifiedcard proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet +proc extractTweetsFromEntry(e: JsonNode; entryId: string): seq[Tweet] = + if e{"content", "items"}.notNull: + for item in e{"content", "items"}: + with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: + var tweet = parseGraphTweet(tweetResult, false) + if not tweet.available: + tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + result.add tweet + return + + with tweetResult, e{"content", "content", "tweetResult", "result"}: + var tweet = parseGraphTweet(tweetResult, false) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.add tweet + proc parseUser(js: JsonNode; id=""): User = if js.isNull: return result = User( @@ -32,10 +48,26 @@ proc parseGraphUser(js: JsonNode): User = var user = js{"user_result", "result"} if user.isNull: user = ? js{"user_results", "result"} + + if user.isNull: + if js{"core"}.notNull and js{"legacy"}.notNull: + user = js + else: + return + result = parseUser(user{"legacy"}, user{"rest_id"}.getStr) - if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false): - result.verifiedType = blue + # fallback to support UserMedia/recent GraphQL updates + if result.username.len == 0 and user{"core", "screen_name"}.notNull: + result.username = user{"core", "screen_name"}.getStr + result.fullname = user{"core", "name"}.getStr + result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") + + if user{"is_blue_verified"}.getBool(false): + result.verifiedType = blue + elif user{"verification", "verified_type"}.notNull: + let verifiedType = user{"verification", "verified_type"}.getStr("None") + result.verifiedType = parseEnum[VerifiedType](verifiedType) proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -400,31 +432,43 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati elif entryId.startsWith("cursor-bottom"): result.replies.bottom = e{"content", contentKey, "value"}.getStr -proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = +proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) let instructions = - if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"} - else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + if js{"data", "list"}.notNull: + ? js{"data", "list", "timeline_response", "timeline", "instructions"} + elif js{"data", "user"}.notNull: + ? js{"data", "user", "result", "timeline", "timeline", "instructions"} + else: + ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} if instructions.len == 0: return for i in instructions: - if i{"__typename"}.getStr == "TimelineAddEntries": + # TimelineAddToModule instruction is used by UserMedia + if i{"moduleItems"}.notNull: + for item in i{"moduleItems"}: + with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetResult, false) + if not tweet.available: + tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + result.tweets.content.add tweet + continue + + if i{"entries"}.notNull: for e in i{"entries"}: let entryId = e{"entryId"}.getStr - if entryId.startsWith("tweet"): - with tweetResult, e{"content", "content", "tweetResult", "result"}: - let tweet = parseGraphTweet(tweetResult, false) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): + for tweet in extractTweetsFromEntry(e, entryId): result.tweets.content.add tweet elif "-conversation-" in entryId or entryId.startsWith("homeConversation"): let (thread, self) = parseGraphThread(e) result.tweets.content.add thread.content elif entryId.startsWith("cursor-bottom"): result.tweets.bottom = e{"content", "value"}.getStr + if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry": with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}: let tweet = parseGraphTweet(tweetResult, false) @@ -438,31 +482,34 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = result = @[] - let instructions = - ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + var instructions = ? js{"data", "user", "result", "timeline", "timeline", "instructions"} + if instructions.len == 0: + instructions = ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} for i in instructions: - if i{"__typename"}.getStr == "TimelineAddEntries": - for e in i{"entries"}: - let entryId = e{"entryId"}.getStr - if entryId.startsWith("tweet"): - with tweetResult, e{"content", "content", "tweetResult", "result"}: - let t = parseGraphTweet(tweetResult, false) - if not t.available: - t.id = parseBiggestInt(entryId.getId()) + let instrType = i{"type"}.getStr + if instrType.len == 0: + if i{"__typename"}.getStr != "TimelineAddEntries": + continue + elif instrType != "TimelineAddEntries": + continue - let url = - if t.photos.len > 0: t.photos[0] - elif t.video.isSome: get(t.video).thumb - elif t.gif.isSome: get(t.gif).thumb - elif t.card.isSome: get(t.card).image - else: "" + for e in i{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): + for t in extractTweetsFromEntry(e, entryId): + let url = + if t.photos.len > 0: t.photos[0] + elif t.video.isSome: get(t.video).thumb + elif t.gif.isSome: get(t.gif).thumb + elif t.card.isSome: get(t.card).image + else: "" - if url.len > 0: - result.add GalleryPhoto(url: url, tweetId: $t.id) + if url.len > 0: + result.add GalleryPhoto(url: url, tweetId: $t.id) - if result.len == 16: - break + if result.len == 16: + return proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) diff --git a/src/types.nim b/src/types.nim index a138dae..092d85f 100644 --- a/src/types.nim +++ b/src/types.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import times, sequtils, options, tables +import times, sequtils, options, tables, uri import prefs_impl genPrefsType() @@ -50,6 +50,10 @@ type authToken*: string ct0*: string + SessionAwareUrl* = object + oauthUrl*: Uri + cookieUrl*: Uri + Error* = enum null = 0 noUserMatches = 17 From 3f3196d10357069153dee43718bdd80b16f5b2fc Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 00:14:30 +0100 Subject: [PATCH 137/309] Fix broken UserMedia photo rail parsing Fixes #1307 --- src/parser.nim | 76 ++++++++++++++++++++++++++------------------- src/parserutils.nim | 10 ++++++ 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index af81469..aa0f8b2 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -6,22 +6,6 @@ import experimental/parser/unifiedcard proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet -proc extractTweetsFromEntry(e: JsonNode; entryId: string): seq[Tweet] = - if e{"content", "items"}.notNull: - for item in e{"content", "items"}: - with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: - var tweet = parseGraphTweet(tweetResult, false) - if not tweet.available: - tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) - result.add tweet - return - - with tweetResult, e{"content", "content", "tweetResult", "result"}: - var tweet = parseGraphTweet(tweetResult, false) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) - result.add tweet - proc parseUser(js: JsonNode; id=""): User = if js.isNull: return result = User( @@ -432,6 +416,22 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati elif entryId.startsWith("cursor-bottom"): result.replies.bottom = e{"content", contentKey, "value"}.getStr +proc extractTweetsFromEntry*(e: JsonNode; entryId: string): seq[Tweet] = + if e{"content", "items"}.notNull: + for item in e{"content", "items"}: + with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: + var tweet = parseGraphTweet(tweetResult, false) + if not tweet.available: + tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + result.add tweet + return + + with tweetResult, e{"content", "content", "tweetResult", "result"}: + var tweet = parseGraphTweet(tweetResult, false) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.add tweet + proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) @@ -482,31 +482,43 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = result = @[] - var instructions = ? js{"data", "user", "result", "timeline", "timeline", "instructions"} + let instructions = + if js{"data", "user"}.notNull: + ? js{"data", "user", "result", "timeline", "timeline", "instructions"} + else: + ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + if instructions.len == 0: - instructions = ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + return for i in instructions: - let instrType = i{"type"}.getStr - if instrType.len == 0: - if i{"__typename"}.getStr != "TimelineAddEntries": - continue - elif instrType != "TimelineAddEntries": + # TimelineAddToModule instruction is used by MediaTimelineV2 + if i{"moduleItems"}.notNull: + for item in i{"moduleItems"}: + with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: + let t = parseGraphTweet(tweetResult, false) + if not t.available: + t.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + + let photo = extractGalleryPhoto(t) + if photo.url.len > 0: + result.add photo + + if result.len == 16: + return + continue + + let instrType = i{"type"}.getStr(i{"__typename"}.getStr) + if instrType != "TimelineAddEntries": continue for e in i{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): for t in extractTweetsFromEntry(e, entryId): - let url = - if t.photos.len > 0: t.photos[0] - elif t.video.isSome: get(t.video).thumb - elif t.gif.isSome: get(t.gif).thumb - elif t.card.isSome: get(t.card).image - else: "" - - if url.len > 0: - result.add GalleryPhoto(url: url, tweetId: $t.id) + let photo = extractGalleryPhoto(t) + if photo.url.len > 0: + result.add photo if result.len == 16: return diff --git a/src/parserutils.nim b/src/parserutils.nim index 00ea6f4..7e246dd 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -319,3 +319,13 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = tweet.expandTextEntities(entities, text, textSlice) tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) + +proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = + let url = + if t.photos.len > 0: t.photos[0] + elif t.video.isSome: get(t.video).thumb + elif t.gif.isSome: get(t.gif).thumb + elif t.card.isSome: get(t.card).image + else: "" + + result = GalleryPhoto(url: url, tweetId: $t.id) From 778eb35ee33cbdc80733e0d9db65b15059e8a3fa Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 03:55:23 +0100 Subject: [PATCH 138/309] Add curl-based cookie session script --- ...b_session.py => create_session_browser.py} | 11 +- tools/create_session_curl.py | 328 ++++++++++++++++++ tools/requirements.txt | 1 + 3 files changed, 333 insertions(+), 7 deletions(-) rename tools/{get_web_session.py => create_session_browser.py} (90%) create mode 100644 tools/create_session_curl.py diff --git a/tools/get_web_session.py b/tools/create_session_browser.py similarity index 90% rename from tools/get_web_session.py rename to tools/create_session_browser.py index 502c7f4..40e3dcd 100644 --- a/tools/get_web_session.py +++ b/tools/create_session_browser.py @@ -1,23 +1,20 @@ #!/usr/bin/env python3 """ -Authenticates with X.com/Twitter and extracts session cookies for use with Nitter. -Handles 2FA, extracts user info, and outputs clean JSON for sessions.jsonl. - Requirements: pip install -r tools/requirements.txt Usage: - python3 tools/get_web_session.py <username> <password> [totp_seed] [--append sessions.jsonl] [--headless] + python3 tools/create_session_browser.py <username> <password> [totp_seed] [--append sessions.jsonl] [--headless] Examples: # Output to terminal - python3 tools/get_web_session.py myusername mypassword TOTP_BASE32_SECRET + python3 tools/create_session_browser.py myusername mypassword TOTP_SECRET # Append to sessions.jsonl - python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --append sessions.jsonl + python3 tools/create_session_browser.py myusername mypassword TOTP_SECRET --append sessions.jsonl # Headless mode (may increase detection risk) - python3 tools/get_web_session.py myusername mypassword TOTP_SECRET --headless + python3 tools/create_session_browser.py myusername mypassword TOTP_SECRET --headless Output: {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} diff --git a/tools/create_session_curl.py b/tools/create_session_curl.py new file mode 100644 index 0000000..f569422 --- /dev/null +++ b/tools/create_session_curl.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +Requirements: + pip install curl_cffi pyotp + +Usage: + python3 tools/create_session_curl.py <username> <password> [totp_seed] [--append sessions.jsonl] + +Examples: + # Output to terminal + python3 tools/create_session_curl.py myusername mypassword TOTP_SECRET + + # Append to sessions.jsonl + python3 tools/create_session_curl.py myusername mypassword TOTP_SECRET --append sessions.jsonl + +Output: + {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} +""" + +import sys +import json +import pyotp +from curl_cffi import requests + +BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" +BASE_URL = "https://api.x.com/1.1/onboarding/task.json" +GUEST_ACTIVATE_URL = "https://api.x.com/1.1/guest/activate.json" + +# Subtask versions required by API +SUBTASK_VERSIONS = { + "action_list": 2, "alert_dialog": 1, "app_download_cta": 1, + "check_logged_in_account": 2, "choice_selection": 3, + "contacts_live_sync_permission_prompt": 0, "cta": 7, "email_verification": 2, + "end_flow": 1, "enter_date": 1, "enter_email": 2, "enter_password": 5, + "enter_phone": 2, "enter_recaptcha": 1, "enter_text": 5, "generic_urt": 3, + "in_app_notification": 1, "interest_picker": 3, "js_instrumentation": 1, + "menu_dialog": 1, "notifications_permission_prompt": 2, "open_account": 2, + "open_home_timeline": 1, "open_link": 1, "phone_verification": 4, + "privacy_options": 1, "security_key": 3, "select_avatar": 4, + "select_banner": 2, "settings_list": 7, "show_code": 1, "sign_up": 2, + "sign_up_review": 4, "tweet_selection_urt": 1, "update_users": 1, + "upload_media": 1, "user_recommendations_list": 4, + "user_recommendations_urt": 1, "wait_spinner": 3, "web_modal": 1 +} + + +def get_base_headers(guest_token=None): + """Build base headers for API requests.""" + headers = { + "Authorization": f"Bearer {BEARER_TOKEN}", + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Language": "en-US", + "X-Twitter-Client-Language": "en-US", + "Origin": "https://x.com", + "Referer": "https://x.com/", + } + if guest_token: + headers["X-Guest-Token"] = guest_token + return headers + + +def get_cookies_dict(session): + """Extract cookies from session.""" + return session.cookies.get_dict() if hasattr(session.cookies, 'get_dict') else dict(session.cookies) + + +def make_request(session, headers, flow_token, subtask_data, print_msg): + """Generic request handler for flow steps.""" + print(f"[*] {print_msg}...", file=sys.stderr) + + payload = { + "flow_token": flow_token, + "subtask_inputs": [subtask_data] if isinstance(subtask_data, dict) else subtask_data + } + + response = session.post(BASE_URL, json=payload, headers=headers) + response.raise_for_status() + + data = response.json() + new_flow_token = data.get('flow_token') + if not new_flow_token: + raise Exception(f"Failed to get flow token: {print_msg}") + + return new_flow_token, data + + +def get_guest_token(session): + """Get guest token for unauthenticated requests.""" + print("[*] Getting guest token...", file=sys.stderr) + response = session.post(GUEST_ACTIVATE_URL, headers={"Authorization": f"Bearer {BEARER_TOKEN}"}) + response.raise_for_status() + + guest_token = response.json().get('guest_token') + if not guest_token: + raise Exception("Failed to obtain guest token") + + print(f"[*] Got guest token: {guest_token}", file=sys.stderr) + return guest_token + + +def init_flow(session, guest_token): + """Initialize the login flow.""" + print("[*] Initializing login flow...", file=sys.stderr) + + headers = get_base_headers(guest_token) + payload = { + "input_flow_data": { + "flow_context": { + "debug_overrides": {}, + "start_location": {"location": "manual_link"} + }, + "subtask_versions": SUBTASK_VERSIONS + } + } + + response = session.post(f"{BASE_URL}?flow_name=login", json=payload, headers=headers) + response.raise_for_status() + + flow_token = response.json().get('flow_token') + if not flow_token: + raise Exception("Failed to get initial flow token") + + print("[*] Got initial flow token", file=sys.stderr) + return flow_token, headers + + +def submit_username(session, flow_token, headers, guest_token, username): + """Submit username.""" + headers = headers.copy() + headers["X-Guest-Token"] = guest_token + + subtask = { + "subtask_id": "LoginEnterUserIdentifierSSO", + "settings_list": { + "setting_responses": [{ + "key": "user_identifier", + "response_data": {"text_data": {"result": username}} + }], + "link": "next_link" + } + } + + flow_token, data = make_request(session, headers, flow_token, subtask, "Submitting username") + + # Check for denial (suspicious activity) + if data.get('subtasks') and 'cta' in data['subtasks'][0]: + error_msg = data['subtasks'][0]['cta'].get('primary_text', {}).get('text') + if error_msg: + raise Exception(f"Login denied: {error_msg}") + + return flow_token + + +def submit_password(session, flow_token, headers, guest_token, password): + """Submit password and detect if 2FA is needed.""" + headers = headers.copy() + headers["X-Guest-Token"] = guest_token + + subtask = { + "subtask_id": "LoginEnterPassword", + "enter_password": {"password": password, "link": "next_link"} + } + + flow_token, data = make_request(session, headers, flow_token, subtask, "Submitting password") + + needs_2fa = any(s.get('subtask_id') == 'LoginTwoFactorAuthChallenge' for s in data.get('subtasks', [])) + if needs_2fa: + print("[*] 2FA required", file=sys.stderr) + + return flow_token, needs_2fa + + +def submit_2fa(session, flow_token, headers, guest_token, totp_seed): + """Submit 2FA code.""" + if not totp_seed: + raise Exception("2FA required but no TOTP seed provided") + + code = pyotp.TOTP(totp_seed).now() + print("[*] Generating 2FA code...", file=sys.stderr) + + headers = headers.copy() + headers["X-Guest-Token"] = guest_token + + subtask = { + "subtask_id": "LoginTwoFactorAuthChallenge", + "enter_text": {"text": code, "link": "next_link"} + } + + flow_token, _ = make_request(session, headers, flow_token, subtask, "Submitting 2FA code") + return flow_token + + +def submit_js_instrumentation(session, flow_token, headers, guest_token): + """Submit JS instrumentation response.""" + headers = headers.copy() + headers["X-Guest-Token"] = guest_token + + subtask = { + "subtask_id": "LoginJsInstrumentationSubtask", + "js_instrumentation": { + "response": '{"rf":{"a4fc506d24bb4843c48a1966940c2796bf4fb7617a2d515ad3297b7df6b459b6":121,"bff66e16f1d7ea28c04653dc32479cf416a9c8b67c80cb8ad533b2a44fee82a3":-1,"ac4008077a7e6ca03210159dbe2134dea72a616f03832178314bb9931645e4f7":-22,"c3a8a81a9b2706c6fec42c771da65a9597c537b8e4d9b39e8e58de9fe31ff239":-12},"s":"ZHYaDA9iXRxOl2J3AZ9cc23iJx-Fg5E82KIBA_fgeZFugZGYzRtf8Bl3EUeeYgsK30gLFD2jTQx9fAMsnYCw0j8ahEy4Pb5siM5zD6n7YgOeWmFFaXoTwaGY4H0o-jQnZi5yWZRAnFi4lVuCVouNz_xd2BO2sobCO7QuyOsOxQn2CWx7bjD8vPAzT5BS1mICqUWyjZDjLnRZJU6cSQG5YFIHEPBa8Kj-v1JFgkdAfAMIdVvP7C80HWoOqYivQR7IBuOAI4xCeLQEdxlGeT-JYStlP9dcU5St7jI6ExyMeQnRicOcxXLXsan8i5Joautk2M8dAJFByzBaG4wtrPhQ3QAAAZEi-_t7"}', + "link": "next_link" + } + } + + flow_token, _ = make_request(session, headers, flow_token, subtask, "Submitting JS instrumentation") + return flow_token + + +def complete_flow(session, flow_token, headers): + """Complete the login flow.""" + cookies = get_cookies_dict(session) + + headers = headers.copy() + headers["X-Twitter-Auth-Type"] = "OAuth2Session" + if cookies.get('ct0'): + headers["X-Csrf-Token"] = cookies['ct0'] + + subtask = { + "subtask_id": "AccountDuplicationCheck", + "check_logged_in_account": {"link": "AccountDuplicationCheck_false"} + } + + make_request(session, headers, flow_token, subtask, "Completing login flow") + + +def extract_user_id(cookies_dict): + """Extract user ID from twid cookie.""" + twid = cookies_dict.get('twid', '').strip('"') + + for prefix in ['u=', 'u%3D']: + if prefix in twid: + return twid.split(prefix)[1].split('&')[0].strip('"') + + return None + + +def login_and_get_cookies(username, password, totp_seed=None): + """Authenticate with X.com and extract session cookies.""" + session = requests.Session(impersonate="chrome") + + try: + guest_token = get_guest_token(session) + flow_token, headers = init_flow(session, guest_token) + flow_token = submit_js_instrumentation(session, flow_token, headers, guest_token) + flow_token = submit_username(session, flow_token, headers, guest_token, username) + flow_token, needs_2fa = submit_password(session, flow_token, headers, guest_token, password) + + if needs_2fa: + flow_token = submit_2fa(session, flow_token, headers, guest_token, totp_seed) + + complete_flow(session, flow_token, headers) + + cookies_dict = get_cookies_dict(session) + cookies_dict['username'] = username + + user_id = extract_user_id(cookies_dict) + if user_id: + cookies_dict['id'] = user_id + + print("[*] Successfully authenticated", file=sys.stderr) + return cookies_dict + + finally: + session.close() + + +def main(): + if len(sys.argv) < 3: + print('Usage: python3 create_session_curl.py username password [totp_seed] [--append sessions.jsonl]', file=sys.stderr) + sys.exit(1) + + username = sys.argv[1] + password = sys.argv[2] + totp_seed = None + append_file = None + + # Parse optional arguments + i = 3 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == '--append': + if i + 1 < len(sys.argv): + append_file = sys.argv[i + 1] + i += 2 + else: + print('[!] Error: --append requires a filename', file=sys.stderr) + sys.exit(1) + elif not arg.startswith('--'): + if totp_seed is None: + totp_seed = arg + i += 1 + else: + print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr) + i += 1 + + try: + cookies = login_and_get_cookies(username, password, totp_seed) + + session = { + 'kind': 'cookie', + 'username': cookies['username'], + 'id': cookies.get('id'), + 'auth_token': cookies['auth_token'], + 'ct0': cookies['ct0'] + } + + output = json.dumps(session) + + if append_file: + with open(append_file, 'a') as f: + f.write(output + '\n') + print(f'✓ Session appended to {append_file}', file=sys.stderr) + else: + print(output) + + sys.exit(0) + + except Exception as error: + print(f'[!] Error: {error}', file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/tools/requirements.txt b/tools/requirements.txt index 4827475..2fdac24 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -1,2 +1,3 @@ nodriver>=0.48.0 pyotp +curl_cffi From a666c4867c9a0b4e187bae6c5d826b4aec34fd29 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 05:42:35 +0100 Subject: [PATCH 139/309] Include username in session logs if available Fixes #1310 --- src/apiutils.nim | 10 +++++----- src/auth.nim | 20 +++++++++++++++++--- src/experimental/parser/session.nim | 2 ++ src/experimental/types/session.nim | 2 +- src/types.nim | 1 + 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index ae459fa..defffd1 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -60,11 +60,11 @@ proc getAndValidateSession*(api: Api): Future[Session] {.async.} = case result.kind of SessionKind.oauth: if result.oauthToken.len == 0: - echo "[sessions] Empty oauth token, session: ", result.id + echo "[sessions] Empty oauth token, session: ", result.pretty raise rateLimitError() of SessionKind.cookie: if result.authToken.len == 0 or result.ct0.len == 0: - echo "[sessions] Empty cookie credentials, session: ", result.id + echo "[sessions] Empty cookie credentials, session: ", result.pretty raise rateLimitError() template fetchImpl(result, fetchBody) {.dirty.} = @@ -107,7 +107,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = setLimited(session, api) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): - echo "[sessions] 429 error, API: ", api, ", session: ", session.id + echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty session.apis[api].remaining = 0 # rate limit hit, resets after the 15 minute window raise rateLimitError() @@ -124,8 +124,8 @@ template fetchImpl(result, fetchBody) {.dirty.} = except OSError as e: raise e except Exception as e: - let id = if session.isNil: "null" else: $session.id - echo "error: ", e.name, ", msg: ", e.msg, ", sessionId: ", id, ", url: ", url + let s = session.pretty + echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url raise rateLimitError() finally: release(session) diff --git a/src/auth.nim b/src/auth.nim index 9f9fe8a..6c52918 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -29,6 +29,20 @@ var template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") +proc pretty*(session: Session): string = + if session.isNil: + return "<null>" + + if session.id > 0 and session.username.len > 0: + result = $session.id & " (" & session.username & ")" + elif session.username.len > 0: + result = session.username + elif session.id > 0: + result = $session.id + else: + result = "<unknown>" + result = $session.kind & " " & result + proc snowflakeToEpoch(flake: int64): int64 = int64(((flake shr 22) + 1288834974657) div 1000) @@ -130,7 +144,7 @@ proc isLimited(session: Session; api: Api): bool = if session.limited and api != Api.userTweets: if (epochTime().int - session.limitedAt) > hourInSeconds: session.limited = false - log "resetting limit: ", session.id + log "resetting limit: ", session.pretty return false else: return true @@ -146,7 +160,7 @@ proc isReady(session: Session; api: Api): bool = proc invalidate*(session: var Session) = if session.isNil: return - log "invalidating: ", session.id + log "invalidating: ", session.pretty # TODO: This isn't sufficient, but it works for now let idx = sessionPool.find(session) @@ -171,7 +185,7 @@ proc getSession*(api: Api): Future[Session] {.async.} = proc setLimited*(session: Session; api: Api) = session.limited = true session.limitedAt = epochTime().int - log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", id: ", session.id + log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = # avoid undefined behavior in race conditions diff --git a/src/experimental/parser/session.nim b/src/experimental/parser/session.nim index bb31d83..2e5a171 100644 --- a/src/experimental/parser/session.nim +++ b/src/experimental/parser/session.nim @@ -13,6 +13,7 @@ proc parseSession*(raw: string): Session = result = Session( kind: SessionKind.oauth, id: parseBiggestInt(id), + username: session.username, oauthToken: session.oauthToken, oauthSecret: session.oauthTokenSecret ) @@ -21,6 +22,7 @@ proc parseSession*(raw: string): Session = result = Session( kind: SessionKind.cookie, id: id, + username: session.username, authToken: session.authToken, ct0: session.ct0 ) diff --git a/src/experimental/types/session.nim b/src/experimental/types/session.nim index dd6be22..dfec428 100644 --- a/src/experimental/types/session.nim +++ b/src/experimental/types/session.nim @@ -1,8 +1,8 @@ type RawSession* = object kind*: string - username*: string id*: string + username*: string oauthToken*: string oauthTokenSecret*: string authToken*: string diff --git a/src/types.nim b/src/types.nim index 092d85f..55d990d 100644 --- a/src/types.nim +++ b/src/types.nim @@ -38,6 +38,7 @@ type Session* = ref object id*: int64 + username*: string pending*: int limited*: bool limitedAt*: int From 0bb0b7e78cc617544231c286a9bb641166010897 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 06:32:13 +0100 Subject: [PATCH 140/309] Support grok_share card Fixes #1306 --- src/experimental/parser/unifiedcard.nim | 15 +++++++++++++++ src/experimental/types/unifiedcard.nim | 7 +++++++ src/formatters.nim | 7 +++++-- src/sass/tweet/card.scss | 1 + 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/experimental/parser/unifiedcard.nim b/src/experimental/parser/unifiedcard.nim index a112974..de4df18 100644 --- a/src/experimental/parser/unifiedcard.nim +++ b/src/experimental/parser/unifiedcard.nim @@ -1,6 +1,7 @@ import std/[options, tables, strutils, strformat, sugar] import jsony import user, ../types/unifiedcard +import ../../formatters from ../../types import Card, CardKind, Video from ../../utils import twimg, https @@ -77,6 +78,18 @@ proc parseMedia(component: Component; card: UnifiedCard; result: var Card) = of model3d: result.title = "Unsupported 3D model ad" +proc parseGrokShare(data: ComponentData; card: UnifiedCard; result: var Card) = + result.kind = summaryLarge + + data.destination.parseDestination(card, result) + result.dest = "Answer by Grok" + + for msg in data.conversationPreview: + if msg.sender == "USER": + result.title = msg.message.shorten(70) + elif msg.sender == "AGENT": + result.text = msg.message.shorten(500) + proc parseUnifiedCard*(json: string): Card = let card = json.fromJson(UnifiedCard) @@ -92,6 +105,8 @@ proc parseUnifiedCard*(json: string): Card = component.parseMedia(card, result) of buttonGroup: discard + of grokShare: + component.data.parseGrokShare(card, result) of ComponentType.jobDetails: component.data.parseJobDetails(card, result) of ComponentType.hidden: diff --git a/src/experimental/types/unifiedcard.nim b/src/experimental/types/unifiedcard.nim index e540a64..cef6f44 100644 --- a/src/experimental/types/unifiedcard.nim +++ b/src/experimental/types/unifiedcard.nim @@ -22,6 +22,7 @@ type communityDetails mediaWithDetailsHorizontal hidden + grokShare unknown Component* = object @@ -42,6 +43,7 @@ type topicDetail*: tuple[title: Text] profileUser*: User shortDescriptionText*: string + conversationPreview*: seq[GrokConversation] MediaItem* = object id*: string @@ -76,6 +78,10 @@ type title*: Text category*: Text + GrokConversation* = object + message*: string + sender*: string + TypeField = Component | Destination | MediaEntity | AppStoreData converter fromText*(text: Text): string = string(text) @@ -96,6 +102,7 @@ proc enumHook*(s: string; v: var ComponentType) = of "community_details": communityDetails of "media_with_details_horizontal": mediaWithDetailsHorizontal of "commerce_drop_details": hidden + of "grok_share": grokShare else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown proc enumHook*(s: string; v: var AppType) = diff --git a/src/formatters.nim b/src/formatters.nim index 7428814..cafaa4f 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -33,10 +33,13 @@ proc getUrlPrefix*(cfg: Config): string = if cfg.useHttps: https & cfg.hostname else: "http://" & cfg.hostname -proc shortLink*(text: string; length=28): string = - result = text.replace(wwwRegex, "") +proc shorten*(text: string; length=28): string = + result = text if result.len > length: result = result[0 ..< length] & "…" + +proc shortLink*(text: string; length=28): string = + result = text.replace(wwwRegex, "").shorten(length) proc stripHtml*(text: string; shorten=false): string = var html = parseHtml(text) diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss index 680310c..5575191 100644 --- a/src/sass/tweet/card.scss +++ b/src/sass/tweet/card.scss @@ -42,6 +42,7 @@ .card-description { margin: 0.3em 0; + white-space: pre-wrap; } .card-destination { From bb6eb81a20b482b6ba1dd6da3332cd292427778e Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 10:59:50 +0100 Subject: [PATCH 141/309] Add support for tweet views --- public/css/fontello.css | 44 ++++++++++++++++++------------------ public/fonts/LICENSE.txt | 18 +++++++-------- public/fonts/fontello.eot | Bin 9264 -> 9368 bytes public/fonts/fontello.svg | 32 ++++++++++++++------------ public/fonts/fontello.ttf | Bin 9096 -> 9200 bytes public/fonts/fontello.woff | Bin 5752 -> 5812 bytes public/fonts/fontello.woff2 | Bin 4772 -> 4832 bytes src/parser.nim | 6 ++++- src/types.nim | 1 + src/views/general.nim | 4 ++-- src/views/tweet.nim | 2 ++ 11 files changed, 58 insertions(+), 49 deletions(-) diff --git a/public/css/fontello.css b/public/css/fontello.css index d022bb5..2453575 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,16 +1,15 @@ @font-face { font-family: 'fontello'; - src: url('/fonts/fontello.eot?21002321'); - src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'), - url('/fonts/fontello.woff2?21002321') format('woff2'), - url('/fonts/fontello.woff?21002321') format('woff'), - url('/fonts/fontello.ttf?21002321') format('truetype'), - url('/fonts/fontello.svg?21002321#fontello') format('svg'); + src: url('/fonts/fontello.eot?61663884'); + src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'), + url('/fonts/fontello.woff2?61663884') format('woff2'), + url('/fonts/fontello.woff?61663884') format('woff'), + url('/fonts/fontello.ttf?61663884') format('truetype'), + url('/fonts/fontello.svg?61663884#fontello') format('svg'); font-weight: normal; font-style: normal; } - - [class^="icon-"]:before, [class*=" icon-"]:before { +[class^="icon-"]:before, [class*=" icon-"]:before { font-family: "fontello"; font-style: normal; font-weight: normal; @@ -32,22 +31,23 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - -.icon-heart:before { content: '\2665'; } /* '♥' */ -.icon-quote:before { content: '\275e'; } /* '❞' */ -.icon-comment:before { content: '\e802'; } /* '' */ -.icon-ok:before { content: '\e803'; } /* '' */ -.icon-play:before { content: '\e804'; } /* '' */ -.icon-link:before { content: '\e805'; } /* '' */ -.icon-calendar:before { content: '\e806'; } /* '' */ -.icon-location:before { content: '\e807'; } /* '' */ + +.icon-views:before { content: '\e800'; } /* '' */ +.icon-heart:before { content: '\e801'; } /* '' */ +.icon-quote:before { content: '\e802'; } /* '' */ +.icon-comment:before { content: '\e803'; } /* '' */ +.icon-ok:before { content: '\e804'; } /* '' */ +.icon-play:before { content: '\e805'; } /* '' */ +.icon-link:before { content: '\e806'; } /* '' */ +.icon-calendar:before { content: '\e807'; } /* '' */ +.icon-location:before { content: '\e808'; } /* '' */ .icon-picture:before { content: '\e809'; } /* '' */ .icon-lock:before { content: '\e80a'; } /* '' */ .icon-down:before { content: '\e80b'; } /* '' */ -.icon-retweet:before { content: '\e80d'; } /* '' */ -.icon-search:before { content: '\e80e'; } /* '' */ -.icon-pin:before { content: '\e80f'; } /* '' */ -.icon-cog:before { content: '\e812'; } /* '' */ -.icon-rss-feed:before { content: '\e813'; } /* '' */ +.icon-retweet:before { content: '\e80c'; } /* '' */ +.icon-search:before { content: '\e80d'; } /* '' */ +.icon-pin:before { content: '\e80e'; } /* '' */ +.icon-cog:before { content: '\e80f'; } /* '' */ +.icon-rss:before { content: '\e810'; } /* '' */ .icon-info:before { content: '\f128'; } /* '' */ .icon-bird:before { content: '\f309'; } /* '' */ diff --git a/public/fonts/LICENSE.txt b/public/fonts/LICENSE.txt index c8d90ff..41f18a8 100644 --- a/public/fonts/LICENSE.txt +++ b/public/fonts/LICENSE.txt @@ -1,6 +1,15 @@ Font license info +## Modern Pictograms + + Copyright (c) 2012 by John Caserta. All rights reserved. + + Author: John Caserta + License: SIL (http://scripts.sil.org/OFL) + Homepage: http://thedesignoffice.org/project/modern-pictograms/ + + ## Entypo Copyright (C) 2012 by Daniel Bruce @@ -37,12 +46,3 @@ Font license info Homepage: http://aristeides.com/ -## Modern Pictograms - - Copyright (c) 2012 by John Caserta. All rights reserved. - - Author: John Caserta - License: SIL (http://scripts.sil.org/OFL) - Homepage: http://thedesignoffice.org/project/modern-pictograms/ - - diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index aaddd6bf3959a71b00b92cd01dd4067db5d94b53..2b2982a5711bffac0cb1aad4690f661e34e308c4 100644 GIT binary patch delta 901 zcmYL`Pe>F|9LIle-kaGO-DaKLSzJZM*3HB}5teR{$kHLKgNjIG3c2d~Cw6skm(4=l zhy<xi@(xLm*v&(Pw6Yg5I&=!`P*Na79r`oap~CGTv~IsyCz>~(dEf8v{T}Z(zqi)0 zbU|DS0Q_7OgG}AbZY+itQtg4>EdbID0E5G^u<_az?MJ@`Aj}RY6V({U$9o1p>Zmam z8#=Ln6MY9-=U6yyV7vtVNu1@e=#A0-kF`GlSR;UQc03XuX?h#@fGMlE-a3u}#~u0@ z{U`K+@mQj7=3FkH2hbP>x}#IW;fl4g5%j(2>to>=g9O!w=x?E~o(#t#TaPL#(XU{E zTgFs8@$7s1X8_$QWYd_A7;~Ak8GxEzzC!vaqka4jX-k=~r_i4KHMl~bzynMr$p1$` zDIyfX$^nZZ3wF+Jj9S@^I2RS-3@)LUao|3)<X7FEV*P>%{}irTC&*BT!&3ac;1@lp zBu?l7FQFpOQ>ijrNCdzY6QzZ9lv#lLSyxI%hk$~!ja7tc1I4Iqz=_%hj-a*y6}1hx zP}^A7X4-%TSo|U225i<LDJ%_plHV0pg*T`icwkGYStcS8=OscYnRgHhbe;*=ili(v z?3`RI7Z)ka!5or^d#I*Jr41gfMiUW&tgn-&)+?~wSE2mwfkEz*%Q>H57WxcyKzNRY zx<f8!dY>f~u?R}xI8Ug}ZcmL{mZ}<py5z4t-Fm9Iitvo;XUEFw%lVFal$O=Ucup-% zdtKj1#YT2hz3OWF4K7;f?3a+SY<1Y0xlao^bSo~b@*b_LsTBrc8Fq<4s>!k-3Fn0u z)JHq%HuJGLmK8h29jX2BdO%OV(iB2k__jMkTGIDC7YWh%JH1vRUJ#QLk?V1u(+6C1 VIv!^elcQ5?XkvN<(oyeA^)Fc#!jb?0 delta 802 zcmXw0O-vI}5T3Vhx3p5aZI=RKW02ss3MHhG)+h&#RY)*WNic|6+HDs@VWka0f;L7% zh$nI5PmB`O3nybJ;o!lO2M@(?FkZYUr;TZ12pTbw)Oox5Hs4IXZ)V?ov-|km>x9tN z2C%d&#QB5$NOHM%sW`uwoChEm0AMJUQ}n0zKQ$s>1K=K}W(qB+<Eh`q;K}IO+*s%D zIpklEy0c1NN8OA3J!WTi;zs8AD!mTC_X4=xnyRFmE73T%^kY4yp-{6xSCKCuZ_{#x z;o06x)hY@&5c(&kQcB}|^F8E0kcV^1tWIK{Cjh=6@|H;@r*1uKY(n0H2DkL7e4(>4 z_yG;LvHnJ%R`pw(XEy;_E5<4rri7-9KcuH9t9vebv3d<I(?NKItps5o0TV@ZAb2di z`d3>+m34JsK4j@U;FkhF?n{A_)zGL}W7e9YDeZ>$Tx<zT<4l)XXZrtZ_y)icD_Viz z1?&kz3$%d@va!yEaFXNYSB}Wo!WIEAt;nX~tu~mO&?tk!^8-Z}HX(pz^vA$_nzc57 z<vJVePSaxpm?qi)rr`tzV47ru{n6d94e$Z%;S2`m#{N2Vq?8bfF2M-_IBt|ZQLkU} zI~_;c<MobK`BW_0V0gt=t}kxH#AAnJb3+f>BQxFIinchZrB^y4x6>n6Zr*{CE}A5m zjO-s=6?=YyNUgO$mw)x)AYj=j_~KNerm^lDIB^m**nl#8BO2M}yxd)Ghc?j_y2B^< zw?c#P!r`#&Nu^G|i;ynkw6saOO5dgPgh<AjfK0#`4~$68>3lwtQPng*KAD-~$Hu4A JrH_Gkp1<)Mv_b#? diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 1f30ccc..2a64343 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -1,26 +1,28 @@ <?xml version="1.0" standalone="no"?> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <svg xmlns="http://www.w3.org/2000/svg"> -<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata> +<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata> <defs> <font id="fontello" horiz-adv-x="1000" > <font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" /> <missing-glyph horiz-adv-x="1000" /> -<glyph glyph-name="heart" unicode="♥" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" /> +<glyph glyph-name="views" unicode="" d="M180 516l0-538-180 0 0 538 180 0z m250-138l0-400-180 0 0 400 180 0z m250 344l0-744-180 0 0 744 180 0z" horiz-adv-x="680" /> -<glyph glyph-name="quote" unicode="❞" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" /> +<glyph glyph-name="heart" unicode="" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" /> -<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" /> +<glyph glyph-name="quote" unicode="" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" /> -<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" /> +<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" /> -<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" /> +<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" /> -<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" /> +<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" /> -<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" /> +<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" /> -<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" /> +<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" /> + +<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" /> <glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" /> @@ -28,19 +30,19 @@ <glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" /> -<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" /> +<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" /> -<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" /> +<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" /> -<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" /> +<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" /> -<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" /> +<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" /> -<glyph glyph-name="rss-feed" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" /> +<glyph glyph-name="rss" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" /> <glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" /> <glyph glyph-name="bird" unicode="" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" /> </font> </defs> -</svg> \ No newline at end of file +</svg> diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index 29f1ec6d6ee124b4f25d2cad015933efaf7f982b..ef775f87308c85319ecd0d8c36791622adf5c7eb 100644 GIT binary patch delta 903 zcmYL{TSyd97{|XeXJ&WC%j{LWpt7vCiSAlhvv!e9ddP}UiB&R{9Nk^l*vsJBX5yur zB|aHL1VKx^Nkm91zJ$<&kJ&@@6huK6279Q`pdPmE8y#uR{O0`s-#KT_cfOgqwjXT^ zu9-k>a2<ej0>HpfTvlFLV!h~>0GP?4^q33dc$FN&hZs?Y<AaTVCeWWmTQn@E6pUNZ z@5i1Wj$MiLe(-z;V7&lElhLpo^1Z2jk5d+L+#kh&`6hjY{v-O@Xnd?^{CF;(2e7di z=!_+Y<jSS;5c(kc`nWu<kY@2d`m5+&2{|5Ke^6P4{vjr~t|U`q&%Omd132|WR+Z7P zGQDzi1)wH)=XVcfX^$EtEi)Qy3iNFLBAlmB;2zE-$p4E$$srWM!~t_5bH<$O7}aNc zu`k$;v(Sy6$A;@P>(9DPK|g^8y9!739eAk2rYm(H<0g)5CsRzSwzfwB9QukK^!IDq zs1vkfhDV2hfW3jfvt|H=s12Y9wE+~PHUJT|0a#EQ=$}e602^S&JAfUqLOUcdH7rDa zgIQ!=qcY=$Qfm`WIKs`E2%%)wOeoM<mcjF<d7i~`@r8V0fxwzsvx&ns)Fzlp8yvP8 z8;1~Nd6_)bZ-&i%Wh&<XDrAA2%at)&ww1jta0O5bd)1f4O?F3(oi|lCG&@ah&q4oz zrfQ;!qC2psyuN~PQ(bMRx-W$yM~)FX*XYfJqRIK39P%IT_ejzisk}bkeYe4vmLxfP zD-jK4yuNFpzW%8j*_f!2%I90F;sr~~FR)O(^zfxs$?W#3Vr@1%^*FPrKDKuF{4f9+ z*dPpXkql#EPB1TM8EvO;*)n#T-QwE0ccuWY*8dG>)@>6AkyNjJg-F>?_S1wo)kddB iASJ-1N5Yp=YL~OmLPt|6b|eu=vV$X|A;_*d*TjFl`^A+2 delta 787 zcmXw0O-vI}5T3VhTl&ZLXUmUBOhs&~w1hO$9^`;0jF3PCNiY?&wA(I*!b%B2f;L7% zj0c0fsEJX6o;Z1_;oyP9!~=(7IC=46IBiT15;WF>sq=RAZN8a&-^{-GX7~HR!-4fs zX=QC}1%QkIz<8#h=r1=uw;<mJ5T0cg7ec7x2_9gOa(ccn)%|-J`B$W#yi(Lr_aWcI z?8wjD$-Um9w*ka{0FOsgm26uzk;0ZCtS2=T8dm8R@>S%WT47;gssCENjsgyZp_#di z(z4RFj{FDmXhB)hNm6<N;BQABnpFzw{;QT&<b7yxU!N;3bXP||p#cxp-|O?LesAaE z4nRlsNo|4>nqgz4uM}2~o%Cw`7F?&J@Ekh{!a6og6cIy+Sa|!dwv8(9ieYw{x(N88 zAb{&q5W;F?(r7T;hS!jfqQ_3YgQ*FI(`YmT|22FA;G`MNK=1<ov_l9wAq-*W5+XRs z&MpZxHX^vs@I?R&GxBkGtp#o;GReWxbhzZiCIs+|206G<!)*b0ZnOY+ry*GYu6Zp0 z*Kh&{a80(r|L9TF0{8)raRvu>V|^Z)E^mn?r_BLPaF%sS312`CIP9moQbBu1_(C$# z%rvP(7)-Ip(&-b)<?*Lo@x|U=MO&NIvYWB^{cQTi-3L(KlMK>67O(xe>FxUoUTUuW zrSfYK2LaEl;!9DP8qDP%K6f58cn1~uMl^CD_=HEoA#J6b^iaGk?%JAdZ|ru{o?Kq@ xI|=Dw>wz88Qy!475F)dUV3+{=5KPOC`C>7iQ`M|EJ)4^or>5t#<yg~4=`Wr=u$=$^ diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff index 8428cf8cf6b3362734cae51568f5c293ef1e9eb8..63c3c233509e89b741c92971efe70a441b972b8b 100644 GIT binary patch delta 3748 zcmXw*byyQ#7stm&jP6omgwmry8gYzCC@DWcVsuSVKqhQVN8>;V8A^$OfGCZ$gruaP zNC}Ps5<j|;c<1}P?|q)nbI<wS6Zigg&yj4699QUVI2=F)pe&vm0PEjfY%7^d9S5UW zF1q5@gKj8fAOJu$LGeET06@$TtG|QW!(a%-j-zOd6j&$#JOjN@6fF$^pk1d(Os>%5 zFfU{fC6_*mat!_-Y+nA4JSiH1LJt7IgmIX=y6NqKbO!)9ttm0e6x8I#aN<9`DJn($ z+XO;EoGuEK=Zy+RQ?z1=uS>z);OyeUKfvv8+%m<N{)<2bzdH(vrZnc_qWF-%P^q)L zx`#x0P&5_FV}dCc9UROS5DW|mqS)Mj>pi2;QnjYL5ct3&5C8)3TuU{jG6f-%+*32D z#Bfdum?d|XliV)0uI3n|rg;XtAv-6)x11>qowc5Fodd7HWGIZ6Oh|Y@&y&*wYS<~B z)U5Bx-D!zl(Y|WtgWO^8e_}sl2>Sjsfl&0$rsRi!aSJK$*vSo~*j&`$z~gaR#qnZX zB5^=%YI>PbPRm9sJE-7y=SxPA?L9)+2gIT<_ECiP<?2$I6^Q9d(@ihrs6)mcKO8GT zU}A$q!D2E|H;0k#2@1!dNcU$7I|x4hD}<TVi1)o!8$Aga<xm-dbAF}r!|FmHGab&s zmb>-$?BVe6L3QqhwpJ2(V)!7)SK{8Dt6ummM9WBwT9i1m<Eyk9Gvo2AT#exCq#F*= zs`MVFt6Z8T@8D84HfDx595vt4zwpLm=RpY0cSkkt;_#BUN@f^78**To)r2mxRqt>% z^b|96oISUBw*x#Grte(1$pLqtzlvLI{O}k&pFrK_Vn_?0NYIxODdIL{16#}_IE2tw zK}<x?V^*M2mf%$%$4<^I44L`pie7QWu}EUe$Ujj;mj}wmo1Bm%<XCXz5#sg6E;#L9 z&|Pgo-#p72037k~iOsuf54C<p^G8M|_9ym7MxL$h`+m4kb1Z9B9YJ%((Bfv_{qQZc zWOg>n+-ycTi%HX`mW4S1_7eV>@ptP}EpyFE4kTQQI-}AlCa>5Qz(g`eZ&;T3Yc;4~ z)R@|qwe)hFVh(+_arr+kt;v4^4eQ{j;pbW%t%g79cqXtXTCU0HmO^fDTLo{gE(R#$ z9jNK{R_ZOC!KH%1EOGLWFSr+SJySG8(B-10*Wd<Az4c1YwvF-#v<OjeKXsh&PEp!? ziM?4wV}XcG4N~}s3Ek+3Ee&)(=q4!mNdeK;uM^rZZ#*Tp(Fy_Of)jPxR@n_-_tv0I z&qj=4jbcg_jA2)R?7fby^v?qPhwjpmPME!aEz>^Y=#hI{4Vjn!iA$E6YSORz64n<F zku;s~-=_sSov6!W3Kb<vb(g$_XpHha&+E?HQua7GB(MeL=UmA!pXfMz9KAjh*ijTH zDq2A^N*x&}3yytL{zGbWxi{lOWd&61+^#2yPxrZLX;i}#aC&4~b)bm*1os}uC<9j7 zIjDduyvtr6k|Z&P1DLFBNu(aHzUW#lY&S`o%T-+8HmMvnXVQIlr<{fPaN1tGd@{Ve zoBTCw<{;Gjt&f)9+*cLlF$>3L%a$4~^POE{@U!x+m~}oKrxweW#vkLF9QKE^w(~c> zW?ZUnXjn&R2lU2#u$@pcNdfy@Sr<3+T#cn!MRpd}$5kx^;rs>)@f~-+(6h&j_nB&v zX!!MY<J9?+II;*=Q<p8c5K7smnI-7(W}UA*sKk%m_Jlx5zLB$6CZgb6XC(%g%gFcN zg1`88j4_gO9xx2Q&KZp)sU(egq!0Y?Js$iih{Xza`MEf)&CFm?&!r>xpwQMY#1;2X zYO%S4KesI1aLHE!NWbaRx8zTrd-GX@wlJ!?<hJ=#e*xSVvqFmn^DwVOw6Y>3m!BSq z!9ZrPIs8ZVH@cDxiz%En*(n~P51uXk82a|dCdjqTF0(u-Q7(xv66?3ACUi?+i;GCS z$1+f^I<)n@OHtGxg=&HR>wfReYlE89?1h&nXooo9XWRvFaP5AcYv*SKGm;O>2Ifvz zC@Hq$&}uKu0NO$Dk_>e5kT|19+)1nFuI<Kku+#Oha0Nc|g7m9`l>`m|$(lbt+3aP? z`&fc8!*MzBJ=pV~ZYEoW&-I>d)n~CM+pn?Dgv*5SY6xvz`O;s>6LyoJ)pN^(?PCI{ zP*y7wCuh-8n#*F!1g`DQH<H*iy*@o`<FU)8ZoO<~$m}olCB3b0B=Z#hkJSyKigMQh z_7u@uEwQ%guqYZ)sj6PS#rFHF0;dGcuih%$W*#??I&4h!1t0NF^DAx2DWhW^1+<1G z@de>u&dgVC>Wee+Gy??<$nuFR-E_GfJ!Jbp-24%G;&yC+l`^y@1R!uM_D{{W6B$9) zVYnmc+frg}n|KgX$h!a0i*G&d@Sb1S++=1j9er|oBMIdz6m|&PGDrq|W@@@7|74f1 zZxsk#E*5L`7O>*<C1*@y`8G$W1UKrvrkHszJBK!riZ-G&*B`2%6Z$uNLNQH3dfbh; z75;&K-adUSkseR|ndvyP*u>;Wej++5h{0SG^x}3PT&+@~_~6>4p1Hc^r1uwaD78&V z%X7m-qr0JRRu7has=-Y;^;i2=0u!E9-SrsAZq^RV%bF6}zT_QC9knks{kqlY+J%U5 znh2W&R>XXxJ~bzVn3o+@<T#%O7?xhc<rMr$!pkHwf5NvJMK7`5@{PGH^F%2vfKS!_ zd$`cBj;atod{n)QwRu&i{4i(AcP90Af>T=PRm*7A@x*oAx_t-FQ977};;7wR9@#Cv z`T?IJvNxIL-q<%=rT+CpI<fXFNEd@0^qp1%+CD-e{*|bgg+=D6yMd_vfVg@LuA8Vf z3)p#B$=(w9M?gr-vpK``-h%q$=7vfYjbOi>j)wj3LDLbK$Q+}j#+%7&I#-3-g_rDG zjol636#KZhU$LAGE;<riwxCK}_O|t^Fk*eFLki?%mJH&ZdaNZTs^s(b34vjF<m=tO zm9OltJvX*rCduv<M{u(q<>Z}JGvlo3F*RGZ)Gk${=|x?4&>!V;14>Oh=EV$cxFe1e zJ@v_)cI~<(#^Zx5JnU0??2xa7ite>q!T#Y1!*TdSw#<La^s)yF$R8Br+gVDlxE}Fa zNbdSL-Gu2{#>96Eva<%$kVR_jwxUmmTxX#ZeFr?rp<(X_;<8dP&xh<ZG;p@<uAlRp z86Ug=e>}~JV=sZZ%Sy1bOX8!j7xM|C)^OailJrxXx=L+J+8Ah-Ub%*tOw46oRjkKA za>0x4vG63paAeYuewlCm9e6*!s!_CFI=|F(2O(<zHr+^&SkWviePnR^^~w3b)1KvR z#-OM*CfZZAGu4e$QE?;beO$@}EGgy6%+Y;Oxh*(zvjthyRHX8e2-RfyZVsm@-BC^n z$NbqO=`xR^W7UH@jfC}UuD_`N+z;~II(B$Ls!B!m_2_H=86X~*QT`@0inoHXYp676 zcetfOL+iJ8$b>X8wqNczz^wW1vZf<+c&I$*Rc_PN<bGts%4e@(b=>x`I+o$kR(I6o z)_H<T*HV8S)-5%JF_a6DACRx)x}7Ul&&v(l&<w*oZ;D83(w(v&MqQp)Textz_1&?- zTm<PMF6Go4(;%KP&I_p1N1V7I+^T4E9X{-V-~l^=j#CSGOOi(3mYA7ch|ep(?~9Lq z3fH=l5I~6pFUi~l9C5%@<(o)p)v0PUgH>H4%everLd?i1vvfxetk0CzI-t&WKa!Nd z-4l6UfWkXhJ9fNYu!X*?jYly@v_Z%8lC>wTgeQ{wKU(;Q$iYYC722btKHH-<+5#IL zEBLg>Wmu1c>SN}w|ExhbL;6VTI`6)^*|Hf$I=2Ggozg^{^q>qwgH^J7tmDwPPZvks zwZCf@cDB!7KK3=DV{va}?JUEHXE$!_?MI)K?h(fLvFGGWx@Eqf4xHks2z6%q+J=pJ z$17}t8aBJ)bh3+jkIf+y0Cbj{i@S>pVE(5w!)({?G{)nXR|FLo61{4!zzV;(xX?@O zdaS|MyXb;*0mcIXL`N3-|0n&OY}?x2P=YRoWoeo8*|{J<K)M92ETw2fds|x#{>c*! z+BjaoHlrjDwJQze9@53-wBx@idZtN+!icB9ebwuG3W5MvKS($&2n0~tVW!O5|6OVT z7eEeRl?q4&p~?Z$0tMl~HV_}^7U&x_A9V!v9?dP9DcZjSJrxD6)LBj;+?;QR0#__< zdrmDH<zF$q=qu@F!#E4Z{~F0$hsV_Ak5Aq474zZ3B({7O@|3UinjF5=V?Lg;q~(0; z)+p!~?Ht>Lo?wy=W0vw%5S7=m9duOB0h;VAOSJf$xyW$Vv?wjaZeK>?5q=Lb<|XhP z)yM|QmC5o3N^QvTuH?$%h`l=f9y9T_{oG-554bs#?aD~kG0fk5OUVjKRl44Yy2XbJ z!7|)7Sz+;e<5w*;Q?VMhCG;sUjZgMMXo0A;&av1nh1<aFF6kGjq7d_Z2y=}WezGj= zBfX>B(u1<^mMo~4yFRsG3>nt_zHxAAZdc)P2ZMumr@sv~C|(nXimZOhV-&065#zq3 zD+6a>rB94i4TT*Wch?UqdZmrh4j_72FcCzF2vddzJtQ}_Doo%uqDgsv#-zrsE+j=& zwD8ee;`HR^Pi24fD-*wZzfhCxIb&C}^^GdwrdpcB+nWoHiZzZZ$LG5^J+gn)ZFClD zV44FD;esP0uHzK7h&m7wls_5uSfd3y6g(Z;08N+dye)R_XQ6M92ct>6bXjG>HCBh^ z+I*BUwEj((Sz3URiFCM<BC-6XvQIx5A;PO&+?V-m2g+2dnq%PQx<2{p-)qZ#liEg> uetgzFCL_r^25F@=L;ms-yakIz1Su(Aq4yimhXxp`d{CKWLFylF?0*2}8o!zV delta 3710 zcmXYz2Q=JG7shvyjb0KpN;XTZkawf5)rl6-6D84EHEOV|5`R6261)UiohZ?X5MA`> zy+rRdgsjfD`Mz(?`JK7<nS0O7oH=vOQ}xfuLwZk5B_$9TL>P=%5Y6A2Gc1-~6p$xG zdcL7LzGz2t7Z3<MNyz&^AfiNE+&B~3!yQV9^9dRS0crvuYZn_wf>s3rL3Rler3K>D z+s53D&`Xv>SSI~Hv^EZ2)&#AHfGY?@Ntu~J{My#a+!6%34JFhF5<uSB0R%;D2`WL< zAqamLxNZg$XV^NrV+oo&A(tSaCaZgW?cj|5TUSQNdH%xWd*{8QIhL^T?Z1bH{soMr zPH{4Kv?6FHgv${6iTe6dnV4Oi-3T#*1ED6Gz&FIs^L`gsD;E$kh~Xk$4Xj3NsbL-e z5zGmUK9g}1ym>ib5oSScokUG<P0|epEgw^KgRr>OI04sIep<!!NMcenn}VubESS7s z;e+9dYyoPbI+8~A%3G0Gx$9M$h&Ng_<q{(8pIj5hI2;u`QR<L#5jnPE=vRgC?Gf($ zH8RFHK4n&S?$sM}Q>~=jp&=p9ry%Fon;k%Gzg_#gaW=c*tYv@T{g>;QqRlD(jjG}~ zc;7t!4EU0krl;R7!GV32XP@C@!Js+Ln^vMLiTRwr{X+4wllLjn53qD8(KTxk#eOZ# zg-11+izrDAA9&9rAa2!<`d<MwH4i{tCHD83usF1tEadOAf(dc4zP%x9XC;mZ4*^(h zSb+5720(hoCjA=6-uZf`@{PxCTNDC6<yO%~;b>d=J||N(KCW7&dpbA^@tB|X)#ozq zmn`uSkKwvwJh_FuI>H62<|$g?+>>x=|6r~5JSuL14&em5rSJK7KCENNXW;#w$5*wT zo&)C>Y4g9l$Y>=eAI`$0Z`qzm5&`$m!SHmNFWL_bNI1FbntAov>u&FNL@q9_t*)&u zE(Y`|6-&E(EJ4n(y=nw=$~z;s{<zjgmu44xn&gzyB?PN@W<x>dmg*3tsT2}=|9&%h zbCWg%8|j7|`=ch<{fL%-IpSOyX1a41E1f_c^H(i9y!EQ8*V_%_KYdRfvA|wPHa~uz z!;jGzsA`kWXu8iBE;>CO0E+Yu*y)6qg+c~wdW92Uillb$k#2F3GJN{wZ1%J`Ip&NY ztw19$fBZC=+1;Wh;GRC}G8-RRD8Q?=Mpu7NY~{n75eoitZ_mZn^KBRRs}3B$^8+HR zPDVttZ<aOB0k+J{neAs2Ag=$hZs_+m;@qDOMz<N=)r{C0BUONUUUEy;mrHrF2XBjJ zgg3CR9Edv!q}~W;-L;CqBi<K9n0UZQ$!}NBGz9p6xay-YeTC>Mj1fywZZXMcxK?hm zQ8^<EkM|p%#|A#hs!~#rYXd<c!6md{I=abQb^&rI#$frO`E?z@V^zr7=V8&0Fkrl# zGn9sFgct1q4cvsTKE#o-craWxb3lf8-Gm}d#q^1sQkPqVn}>g2SypJ(RT$L?`*t6k z3w@IEdZ5P<-lF_vdMtJbZ1|2z*k^tWD>)rDb5(DhQeAnVt}Y~WI{iCgI#(*7BE2cc z#+4KKs&gZJI$;_ZdsCgLX)ASZ5*4F>e$~6SR?T@(Lso;=pW;=GBejF4a;dM~Q6||k zZ_BL-t5|Zg@68QtHR@5JV~(}tPCW{wgx+mc3PqFLT<+%broR!V1|PBJon!f>=B_Z4 zy<3jtWFv>TH?75ywyuAOgiR_j3yGu(8txy-)qIU><;w^1j?7R|m+m3=DgJny`)D>c z$(VkFtbHljm6`}G5LElMbTn`xoQfD_+Mk>wmT^E-uKXj(fhN1oIt~xz+p=?41)bl} z_=tgou_m{K4tfda+`N#6W@bEEilL<vrh7v%trT{a!KKrqG3e2g7~UoI;wNf^S*x$$ zo(;MjDNqhbB9(qQUzB?RTN!y(-(1~nUuS!T4j?pX7l*QyODXD-Kl0?$rdo=6eG-_W z%WB>?%<z&z=tN?f3+_c`(GX>}l0z7npX_iASdd$WlV<U-!2+Qi%P&zS=c<Z)vy2Cx zTd6H(va!<!?rp+PLBY=%xRj7qPb)YC3m>dBnN$NXW^)!!qju6|9C(15jiWgpFH_&- z#Vigz*sm`NRJO3OqRGG6Gxc^|i2C2k9fxYQKYL(tVG*v;k2D;(Z$iZEGP()7-+yXl ziZY0ly+Ij5;PTGDA;~QwEAn_NBe&q3u#t_`<ok5y;ZGtS3M!p0p)k~Mlu~VlQzAZ9 zWR?LOq!6QL%G!?(T^1NrHaE$i1!avY3^ycz^YPr!TM!D9kov(3Rk!NXxxD>@97deD z01JL(Xl68$g5AqM8vwmS&f9S~Hp;iWl5KdDuvId0wRg1G*iV^#ik*+vyGU)2^Ngjn zvuB=5T+8%~x$%gej6bsM-Qg`%Ol%QiArOcb|9!ajS|k%0Q#Y$z79zPa`+A^=6@hHp z^7tMzONJTWb-H57o2}eC(UA7zJsK2Z{_yC1W=95DAW1gmX;8i@-wA_UVz3*z7K}JS z(^aXu44#iiq4-DnpH12Jkgf+6j=t>8>fkJ?)5iGxsd^dM(pjS7Dvn!fc&N|fCJJcA zNayExcuU?CzXvvNm_Tm)qU{I5ZdsAGJuoHxS7I;6X2i-iZ{fiQ6lZf~&nNh1WA=iY z+q(hizINuHx6e}+JJd2LS6MrvJpQD0x@kteT}V5^97vUV4{SH&RkI$iJkMfUO%2yE z?^8$T^-G;m32nYrfC~(dP3A76!z%$-dI9sU*!xZ+!}@{)yEG)6EjVa<#7jhBT`ktY z4<4QZv(eTzys&%>Gw9*^s>H2d?FU{dekSC-h!>OW?8QDv{>keA_CniRd9REgg}8m( zUiY{-xg!<BhKav5;X^4IDAV)BFSRQ|L3^YtPBo`~qko@qLjWALf%Vic(+6mh8x~zC zsPbKy#Qh~WVS;v_0wT!=28WEMHpl2P^mY!CqaPmS`!Q&qRA*jR66=upR-NdRn0=ep z&dBw^R%dH%<ZFEYO{}QP;CKGTTK4ev%NFrPic@@As6wWRe~AH;@VrDrWny~SyP}{S z7;<U^%llEKa|V4Y$XL&?Z66TI(pnp-j|=n7p(O6(|8d$uG$C)xxA-n(HaF0?Dn8W_ zR(h0_OeY3Eg{D@zx|T$AhuT=tx{i@1Ud)Bj6)0J%!RhFD0)jEuODQm&PE&W5vDK8B z7wn%w@=X2d6S#7b?@{q7jmX6(!v=>14V^JfRkG_)eIX<JWd^yIroe5Zu>%aZ7Z=?> z&p$6mtG+O1VDQa^Yx#KYtu7z6h1_VxK$tEVFB!Jfk}OreXfqmiX;(b9Yz?n!qY&AD z92L(W)V3B*vf|%wui<ZRll6k?;B03yGznoS#%J7O!L~?T)KQ$-(&?<H%-27nOFJNa z*natZ-T<Y2m&7A;6a!$)4{ASDuQTD`(JvCnr+q!Q5-g4n6M8O|&NkaaJ{!-@8zxD) z;xDL?*Pk>O=6aORx(ZC=5(g#U%!mrimbr)q(4RpHVcb_@uv^?=KOK+ow4Ue8o<8*r zu`>c~|8!k{#o5)kW?HoNJb6~1MCB&NUknF-m3eYv_5@u4Ne4a_Q-exoSL$l3y4!QM z79i2B$0Am<xppNOf_DH$BSJXI*m%-nAM=_Zg@&|D^sG+#$Qi7iFn8zly>C!u{<*6Y z)DT{gOqoB&QMz^WI|H9O<WgmZq{+H2*u1;cNO1eP#M$}1I<;#Ba!Y5ltv+H#uDtgP zi(FGP2~!-^AyD<@yme8dVrfZ0|1?hKtsM`?sQSB7CN8o1N*NLFUEVC){quM;g`%gm z)8DegBy?3gCRf!iC1{+DeB@M1(wLJd@;%%)%s#D~?er)QZ*FS<fNh4-0LplXNxISN zy~f}hjNCBe@!)$c(~SqSx<o_eBN$EBu4h-o^mbGlAmG2&%+kyZv_v~QmV9u5G-WwS zHU%j%JEfoQQDt9WU#E9lG_z5zxbpJ{C=fmx)#d-}9HEE$EWN$CIg3zpJ@61hDN9EW zr358%L-+`G&#$GqnT@!O!vEw3Jq1xY5$#ugetti5Gqa)V>$}{YSu{C{EX+wMvQY0N zYEZezd#RuRFz6CZ89{K1gux541yzB1L0e#3@E{Q_Q3TNvF*9)*@e;`slExd1H?klQ zf|VqA@Ay0P96;aG@3QntO+#lBff0d*zn&Vj2}6F;lZQk&xpNK|=h&s><Rl9wGoIaP z45C?9J$dM-o(E&R&F!|AEaAz`)XdVDQRcUY8ZUC`vh9;V$xa*~E*qBboOT~@+`(e% zHLurdqV2h&Sy7!5eEHLK*<{jee7M5fof0U$57C~R&H&p)mvN_0jn+W)@g`>{hEiv< zCV=nH)I?=&ciiaH%K5lv8ebNK;&Us-BvsVc0%Y!yWf2AIdMzF%HM_5h>pdKFXzh~> zQ%~M}>?8$bb1cJ<nNiB0w^m_fMeIK&Hwq+cD3e*F!a28d$NR;1@xKlS-*!-{GFv+6 zl2gdV1J&(f{-#td?^#-VF?yjy_gqtG-S(b*PKbLQgYM1Gh)Rx)mr}UU_<BL{VDSp4 z+dIBBkCin@jny@A`SI;aZ=ACrU+{ZRwBMeIMau}0!4KolhACTmfp29*f-hebtJ}i7 zOSd9&cQg8q)347VqtAmCu{g)xnOlKSdL?r|Mc|%<P3e$4okKj`R47WrIbCQLZ(Q>3 zKKa2&*tG#Y&pqwT(7+CADm-c->~??6LIkV9PsOo#m0IsdIF*+i$`Q2-avCkZmiw;< z$4Mgkx!5ODcEk$1XX?4)44HG5MG6(X?)Utu?{hHKkWNfA74JLD=5!tIj9wjq!`qmw O#fW*Jpm<TnH~#_s{>Ul- diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index 551f49d02d766d86f92cf78bc80f94379da58c6e..b7541f0278434295a1178b8d5a6adff4deea570c 100644 GIT binary patch literal 4832 zcmV<65+Ch%Pew8T0RR91021H;4*&oF03+}K01}A+0RR9100000000000000000000 z0000SR0d!GgnS4N37iZO2nv{dl^F{v00A}vBm+<cAO(d@2Z22dfgBs!AhWV=kXAoJ z_Fo@xV+eLedBvzIT6C3Xd4>~VPdJ_!2WSTttvBuOt5t?NL)YmCJG~_qJ(8)J$XD0k zAj9=sLs%nncEeRTi$yi=m=Ti9^8CZSdT!<vjo^<bSn$ck(li*`toSs;|8LE{`-+;9 zbTtRLhoL?&8?kIN$M3RmgpQP0qgIcv?oFp-O-q(@BM+dC_4HBqO75=WErA83gw6@v zv;?RnH4&sD#vnk~I&}O!tIc{nCQ0kb9ILFe>Qw7*T1%LT*w=n#cfU6?OMFBsM2Y~^ z*2b|s{^x=!lE|2oF)EIH;t!rrTe=Pu6_5g7`Kj#D2FQRqO8D%M6qI}$hN9lCdRjZw zR~cI8L!l2uf;to*P-tuMu5E*V;+X#}5^!3g0hCoYK%W2KTwnIxN@{)$plUxz!k}d5 z(v?n5y-Geq`V*!Hc!5YLRR4@5$^xQV+ivmrwd>Zn6>_a#VdM^D7l4UtI6Z30w4#I< zC65(qHZt+QrGSwoIS|$iuivRkm-*}aswx6Npm}I6U%kkcUF{_Z1iMN_ecAT?)K<OF zy4x{My!3wi{0j?A&?4J{|GeGvK~w!zUORU2U-NM1L0?2*)uOsoTf70*m<KxFpF~t$ z#JPFA@NNZbCpoGXhw;f-`9DgXfZjEh@rE$mBK|)fo5STPC@S%32_%$Ake=XK6LZcO zTMJ5Ei7cfhOKHneI<l0mETtz)>B~|EvXr4LWh6@(%UWszrA%chGg-=9ma>qgEax1? z%4+t>(em9G|L4Afg6lAdBG>+6Y<~@P`5>^i%=kV%m-fyb%=156Y_BG|X{-hJfZFd6 zoJ!t7pq-yP7}TnDa7eSoXZBSmdqk9!d|@*^Uk2T$V7&8FC>V^r+#t?=cC4$5$<ui| zVqxrcrP-;IM+8RoT@<2T@(Rwvv)HA9EsR8pClUtYy{9w?{Gdh};jb|wKIq1w$iZZQ zs)B`k+;|usrcHZaxqh}~qwCljjBVXVDJaPeV;oF)cVfvdMWVl^$a86Dc_Nq7&bG<c z9f%FuXh|_Xdl8sZ-~?O*a9Cx#HU^7kY4b4u)$)pZ-oH{_#@OTtr5=E%_!W{;eRN33 zKoFiA!Qwbzna4J7tc_51C{=re(nzsdUIMhJvB|oAY5`d$$fmvc;FPxg`NLEyOT7a` zTn}k%@<t3AZ4}I9AlB$4$j0Ry7H~Mzq07W%e<;Vi3b2C=YdweBP{wLH90<^3N}T)^ z@=_8PF*yd;r+(pDE;?sSP7VCDwHoqnY(;T_!7EczK!hh5w7nvYqU*41j7i$d7-hA< z{Wk8We@}{WYw7<0Zni`vEzIlRDvk)>j)OI7<Fs}7D6FgE^_#?@AxlK?c{O%^7%?Bw z6S#m*909bikWBe2<W<`%1WeLC*X~`CW`?+SClt}?$CDJ_^yiMEzV&eJcD>eE=|n5W zD46116V?}piYG`B5az!4CFqMmb1HBjhoZVtg!^_Ti%fLW>X&d78yc~E_FGdUK1XPn zKe-7*-4gS?WPz-$4-xGAQV7j2EkX68uI-&bXkG@C%u_Uwy`(?og((CQ$w}{}r}*_j zrNjd{M>ESOq9aAYZ0eK=qOxFA5uB<*P)$gx3q=iKQB&B|5>8hR)%`_L`P!YJQbW&c z$`c)P0TrqHzU6$y(ET`NZB#$ZlZ<^(`Bm5GMzWy;MHh-56n!WLPz<3MK{1A60>u>C zy8v0VR$5Fa%2|54jC$rQQf9$jYnE_hk~}?4$1+4lvLuU|XLX}~%c<5#PAi34v6T8M zXS)>3%_76>Ae@d8#;dBi<@DY)$OX`y>!{G(RQ#SAN-#oF5p(~*$27}2Th>A%=PB8M zEXoLa3n2dkIW|!rSafWKOCV03zQ{HTlhmb_5wJC7=t?^qSscj{Hq93I4Vdq?0?qE7 zcI*JmeP^Opc=E$!mx7$c<IJ;m!9*6@EwGK<2B}#Mpv*^X6P|BRjx^_COX%*lsUvZB zHTtdWs7q;kk*BG{gyO1d)0VEVN4kJuY81)bsAB}PK{C~DGJ0!PdHDiShk4d0E{8C0 zK8J@*QnHFzPdW8zBRwM-wjp8$gfp+fR`dpZ2BKq64{M1CGPdaA>~Y0@Jub}*p<W<o zJ>$R^q@ZPQx6xIoWl^mO>sBCDiojast2<peZYdb>W*t3PQr7If>aeRdAGV$~(az5s zp?OCPRB8=+IXhz*n~fM`G1g@%#4|TrpV)W9jPL$rvRfSad7lD1X<<`OgWc4&wRW#Z z*lvYOp;)u@n$FZ?&Nbtx^|4IByx7`0jXv*}T4!0O&!V_J?%{22&;N>%18aHoHtgw( zI%}+}G};J}&UIlUX@t+OJl$xutR=aT__z?yuJUyq)^4-sOl>rG<l@jJ6of@Bn<Tjm zwi{i!8s;jMT83?}XsYfithtseQ|cPfJK-!tNRYy_5F!E_rQ@84X+-f^2nj(t+1(Ow ziX!EZ(z6jV4wPLa5ONMFKO3RoV2k{4MwD<yl#wPXIB2UyoEJ4+r1~s`h9GJ}!fDa+ zo7A3-&~b>mNF($dQhzqWz+rOOE!ttcXf#oaU$|og>_p*y0YNi8jG-%L5@#-<MYDD? z#M0e~mAf-u4-updfFIi+kaiN<H)|Iw9Nb+vy1Q_4ci|i$x=83M;Tx>jjk~)W4|g}7 z?ryvSL~jXwB)#9ccjr;<y9dhkZZg=xSH&6|-@6XBI^gbIvZKC8uR;MHuP`P)fM;1! zzAobfpzQzv`Z@q};EbEYbbx?7xJZB(W`jFx6vGHdYZxBs88%#P=MU~Xj-^cvOx(oX z-Q2*i=WhTPwP|2LIRla}Xb^wbGu|UnzGNAN2^R=1mnVK!xe@K-MFB`?1<=Z_k+b)? z2;uBS{vcDSwg%N2wT+F&CO~tb&%uSF1ei9I4@{6GU5H4LRRVdvav0S6WW=gLg^D$V z)uF)HS)9UnLammoLM_im|HtQ#qH{!mYz{5tFGBbdHFkio`d27CfcfwL&9_jATTdqN z7DzcU2RJ$l8PDcHolh2E7)7usH-17lX72$I=%42qL82G<eDx#+%L~M`MT8ck@I{oA zp|gt=rX}YZ0MiH&sn`@(S~5_TjTxb_*1^;W1D$1>;`1&#*aF9ln4@F&Z{EFd=f*w6 zi0;xS&W9;#F>nV0!Sv;n4?Hws2goH#+<KWBvk__tt7@r!Lx>8kLzULXxZ2uUt%m9D zY=7^KKX+u*5(3JDM2uSUKapVc01Q(BeH|Jb6GCNWrBVSj_-LQ}kZ<f+f59SzQ!Uy} zBrwabLP;5ZbSa3baU}qtDne6<j#@(^rP30@t(QXherrq%(`S==r+kcv33S|>Mv!uT zw<W?CC_VX*K<&*J2s1TgEdYfsQw=g=4UCM}Fv3^aAr-8%sYQ|$qO<rswg4074pl<i zsR9ZDj}S8E7al3pEm>|811Q%iY7qQHWA*Yfs$z8a!Ly^Dg9l0IJwh9E<^c8|8y9Vf zN=U|fwT(@TlJe<Xz<J)VKm`-R->7gE;X_d%_$P}3u@DmJ{r7ng+~0E+bALZUw?Aor zsB7!Vt%rmUPCj6TRkfxioJ~uTW=XTEC;v(b7mt5jsknI5-0WaM{>Gnw#(}z$77Qr8 z9+Wrv`Nv~rm2Cre%R7A(H5J>vMk2Oe_5;m07VeYGe_7I)&s7hFVd8B7FRP%XrF@Jd z>3rrr{UDBvJ$r@*K7Sn;*tKiVfNp3~-krYR>`d6k4!`5Fu+;ThdDZvp%h#I5-nzQp zMuj3RMPbJKK0Z!pxp29PZ9TbR**%Y|8+#l3`?E4~<IS%6PWWGS$;jyM8_Pb^^zoe8 zSKq5<7G}sco!D^NxPOno$hXgb+;_}Z<bTdLSh8E<o-Y_Mwsm|fL~rj;->(neV8zF$ z%C>Fua<c>^j@Rdev?Og{4JB_cAZ>{(D5x*UtIt#CD+raC23;)z<B|m6br;bu#^`(s zWyg*ei5wlBcCXXx(HqtqE9`XclKMS`X2V_)iR(NOdhdJmVj*S!dwKPNK5q0qE^Y&$ zlh^x%7tu>%+-3ujsA`ZKlluxR$~_w6Y;l}1(+J0~qmOOr-_h&bD~9(w?<*-D*(VI` zcRXKQjDFH~e?od+*4yZC%{>5z6v#|iP}6bpfa};XvdfV-E2EMO(nK<*<fc*6F>>7X z;Bm6a0Wp?Q$p-17ADKoy^|lPIxhH5t&zBh^$)Sn)bMmVR-zO%r{Fb2fjR7Z40aFvG zC@AaxK2b-<(RJ&NceH`c1L3+)`aPyS`k&L9abslmxucQpr-h(N)8F`}NrZ8lu!iI8 z)MIyXcePVrWZ$$iXY)D6M!xiE<*bfmx8~=!Gh2l0wt7zMfT)F?m(OgYc@=vxn%VWu zoLN>2n9|A;4X|78%A;jbua#Yp&uTB`wsIz~rB#-1tgP$;#noU(xj6MqiMJ#-HJ9&Y z*WJTdzD~ebru%0G3f8O^Gpm_V74cG9TW4*MG(g<nFAk6f)?xS;aYeDXgta!ZnbWCk zH@IBiZg5pU6Phu0G}ZZaL;1F><=2g%|4=#VpE9ry&f;!2nF<dx_1qBG@B^gxYH8YP z7mi4=Kd8tpBPs}yMK!Nw_1QKzKL0#4xEtfmxwcjp%#~-@DZ*DL8S$j4w20TdC`o94 z&)8!<u5Erz&EBH1DT^esG0jx}h6n)wd*1pMR_@#B)})(VO0^p+0a|9~Kd+D4X8)S8 zO@Sfn5sg`&43>46fG4)?4&;h4SIB$H#Q^(|bw~B(#xJ;Vln8hdSD5P0%~TK!u}SG3 ziPY7Wh<D|b;@L2Wg8VsYTaD(XQX`NjiiD#em;s*jyFI4*@}C3eW<E2qk7l+JL(E7I z8JG;3C17ytfw@J=qIB`S-Hr9hxZkZ+lfcSM?ulTfFCd@GBR4zuDwZ!WXZc)Lk|8FG z-6AD2U2aL@Z7i}+x7+o4xl9<JYWKRGdMoCdjdJaKs#ek@j>15foQOVVTBW&}SHz=& zC@!@L60ULrbQgloYM=1>SU=MlDy)RcKh4cX%*d()E9Fw&h2b_6@)Hmo@>wd;7_AEe zB6{!;{4(6bZ3GXe1HF(BdzDPLf>soTwi?LIDgcl09%3#9Gezq{q%Ld4l!NKep+TGk z;g1E58mUi<s&8UcE5=#K(`q^F67dd+f0Psx<Lr>4r|8o*35iNwH~{C%!(B;moIPpM z&Y;{$lX2v#vD{1oVipPM1PBwC(rkcmJ_u){EE)+l_)cwGABNxwm~4raivkmRjua(c z?G0161uZXkdVVfi4dU)<lJNNOCkFjqy;fD6tA>><)f_olwNj43BV;Gw$gQ{tQ6vd3 znok!OrYreMyH$}ET)!rHmk6^g2x)tkC77eYIx@S&V}djZ;;0u|ZvC$)WnKEgzkc5g z;U@D3KxC!U2rv&x3F5N2-FLcXMxh>!X3HP`fpJE0-W>Lo8qUqh$!O4E(!)R+X~8{D z%H^*M`DH=4R*dAq1h6G>Jqo(R6f{vkysPhLH+|;s{crpB7l2>J`KD)%!+||t;<tdC zWrfK8jks3$h8y~KQ|&gO#iCYUJm1K8Bmbg~aawHP|HL7Arf;BjgXW_vEzi_;dhIh` z+`<9)%LwB*f~SD{r2=qw13w|qwqUfB$YI-3Az>%mnLNM_<%y*MMO@$r<?W>ftU>pc zi)i8%4tgHIJ$yt<i5mT-LWN^&P|mYMxxX}^!z&!2EG=FNK@k6GFue2Ue=^Y4Ysq&Q z4{uNh{`GeC4Q)D)g?{NdM8Q|ig!Ql&_?vZ|sZUSe=<NfgUm|(@HITf<Du>kYQw0NG zog4A`z{sA>k@Bcyia=4*UQ5x~F!O9=4tEyW5nftlVHt{8p@2eUAMpmU#MwGw5wkH* zzmI1#Nqj-AvJgEb%cI=%BtJ&YK2k~3FB<6^M!-3;B1J5rT7cc^ix`iQ$0G?~<N<oB zqEofK$RV?~WBx4aYlH*SvNnjfOtuCLcqd$Zm2qe^Is+pUbIh~AB1<f@!YXSqi+L<! z87rj6Kt%d1L}WgPN;+>s5wmsT_P-@^B~&rK09A}9K_#94z!IJjmdFuc=tWBqi$11u z7#RHWA{NfuGVb|4k#E5gz9TP-Ah`64AC~}$XA#hK!V_;H(n$zRds`Qt{sZ7uU-VA~ z;PhKRo$D`PKKCa{BF1fBTV)+syvP(^b;{p9RTOWUUft$ue13?^^p}5z_!YJ<{g5>Z GNo@dwG#q~b literal 4772 zcmV;V5?k$ePew8T0RR9101~7C4*&oF03(P101`?70RR9100000000000000000000 z0000SR0dW6g%$`737iZO2nv`Cls^k700A}vBm+zYAO(d@2Z1^afgBr&AXNuyj{~vB z)nxyFNYKUzMcaX2n?ji|pw{Y7@lv-)^UAq(HT$QmmPZpoQIJ4cyvmT)ilW<P;x4D} zvu{J|;zQwwC}yU697^QNfH8H`SW((go5qzhBP5xH=l}cj`=4uVoO1%-q{`<)PJu`% z1>nga?vbM}H`;Ed`_!&P*R`dX+&yp$DD^INt5~3xd_{dmhH>QBxt;p>w@9y@wm%nL zhzFP_CUDadq!MakKt+r}fQmxL-!q}JUXMxAdNK#^#Jv8du#zwnf#=g^hM)ve;443s zJ=y>rpiBw-9Fl_C^PcOJE^2kv(>mabS<6gvNKsX?7VI=Zs~Lw7XD<}D0Wx$(I6dd@ z|2J2keYcW2Qw^U$Vm_fTOOl;SS2{VVN~Zq+rDth4|8ug04a+DLsySaWbuR#^we1#- zS*2FUwQ_~=JB(e_L_Wr5yG1pi+fd2DDt!6qPl5mmhAhza8@KN#bG3H&I?Gs!l2kdl zwOqYauMrkTuvkD?@`$sVv+_k8=wtm2@WJ4Zm;W=Ig5nI#ceVS%hI9O(I{zs$dtdfB zYWDFBdJYJOFob>V^vW-h>c^2l3bPD}8J4G?q#(zj9p^hwHUddm#gCg$(aTU>Q$^AW zRhxrCA-DVru{IsvVbVKZ{vRG85ebr{NRuH;PF?}xfozI=gVF_M7aIb}cfLS*LtuDA zV0lB}cthZMLlE$WAj~%q$Tw~v-?)Q(LlLC%4OO!kYM_CTfCfSUK`#GsBgp4JMS?;_ zzGdAs^a&gYgDCQ9N#^Jk{^UzwY>}?3G?RSb61Dm;q&{j+b>CPEM_`@yTZBA$hk!WG z9168+9R|`Y%+#eLzDLm|C0}%^=SzF1qyAwoq;!8fy}H33&Rgy1@Z@Q3->`^eo0&dZ z-H<b(zAf@({74GB!$EAz+z}Yrgb6fxAa*^KUF=6S(g=STV~a0s7Z3YjI>4u3F%~CZ zLt)b7!@};vx2ozcrbgSRuA&qesvEknJ7eeAkRI|yf7nfClHaRON~rYFF<K`^Y=R~R zQjBG%L5-A8VQ+wK1!bSU!E9A})lR+4(Uf@5rK3iSO&yW2xy)1i1}VGx=x~sMV6AS1 zz{i0=J)wGIZA7xe%i1Osww9pb1tWP4P1I#?08YyxTxk;@-6h9wzvfVex(!?Gg)}zx zBnFK(3e}T9v{8v0jiWgN5P7#nbxh&Miqxw>YgMC-8PY^DLFtGfj2<H6)1{?`054Z_ z#CNBD`a}t|(Fpm#cdXG+veJmc6oqq3P*{hxB!k>qOC#wnmTO}d@gh}e4PY!A_Ba1{ z(yMmT)&Xi}DI>XQe32fa&NL3zsEr-#$f9UR;#|#;BL|rhA?q(!F6>*?M{+aRi`<YS z5A8^#0a;pV=GL0LC&{OUe3G=00$zOx#kTtKF6p+;9mUV+UU>Ds)M>0Vip!T!DELRe zu-pl$J^2v%*{bGCwv62EkQo(>8C_(T#+uLXNsvRi(rSx02@Q#~FgevYlJ$|WQ6W-A zQ}yWYj0G-eA8}at73Rv<)`TMkO&<aU^)eu%p0vVgFX`WsxuY~r7sz`rJe3+o>Qd?p zydM_tL`NBgrr@Y-OlS#HTFMM9W0saPM=O}8l`NoDETq*ey1o~x`orS$s}Dh?LhkmI zmpl3}ZDsTEqWx_h=F=XFafECTaTKZllq6J$hGxWQfuI$FHVE1w=zyRTf-VTUA?SgO z3_v!Fl?IcEC0TlU4|@6>mdt>?#zSB*jC-ea3`1ljTXI<I6+NiePt-<m8YvcsOQt7T z+mRS9i{t4*xE}=!M`cq#d1MUogz4+XaReSCh4<=6V8cTVQ~pQuPW>%iDPue><{p`V zY{-c90a(ZXLXJsPS2i3|-i8pLmY!xBMQO&Qk`c7-De;T^-i8iaYYF@2h}(qvc8YoW zV#hH9D1TQ(r?t`Xa`KX*illcB@_>qL<SB3ovprJL8po^m`H6+ohe4<wg@u)qVivYI zxe&cqA>oo+eNR${3GchAPDi@p8R-z#Q=>Sq8|B+|tHF7ySM?v&DwU@wbXcz#CE-KV zuoJ>_Cdq0ArBR(U5jm@;svgSNA_B~mS=^)O{lXcKjyW}~AtJb9KsQ$Y+vdATko+N( z0m>~{T0lFT6qRg$HrO93*-&e|(hY*sLxi`-BVJs$6g61W(%mhk)!a*q4M*{A)i$ph z=UL9v@75|*Dg`z&q~c+0CNaoHtjZ8ibu$-VbIpkvKla-7>h}GX*MXT3*zW7WY{;>e z_VqmTl(z)lD~76RLpNpH3=5@iuSu!rTN<aY<zrIm3hneplth<#c(Bd+Uoo;^DULjb zy>>%ajJ1_s8(E}H*E16oqBcKl-D@=`1*za4Tf(MYzN=;L_AkW5M0r`Z#@kSkTPKrb zXgio*X~*#>CzVQuxwTeQ*G?F7Cp*r>F0?RY*v&YCtSrE0X@`sr=1L$h3-DRV01F~= ziqHT>IY4Zr6F-bu%9v%0S<YA&;UTdSiPex;9Z5IH*knyPq*fNtW(6IV5RERP(L*%) zh{gcX7$O=YL}QG&Yl1>dd%<+36D7<Uw4O`cNyGSyUjRxkGY&b&ii6hu#3^E9a%@c= z?$R){2cS^7SUPafv7ZDjoJ@hUDR40bt}f7xgYF#a0ox>=ro_vXc$*R*7wF4DKaP=q zAbyiqweKIl*xHWH6x13Uqwf;7F>B-biTW^ZOaytEEX{0@=x~%a=tp3#kWzgYejW+x zA+r=47=S{597S@;WM`0C89SV{*(eHfIh0K_wPXaPT6<8G&gF*w8<7(TPduAM1v5B@ z!_~j4#g>AY_#kfPLkr8koP9_Wgt1J#9Vtq+8>rT(-Q6|r(VG1u-u@)XN6n#i7&t*t z1+WBO#FxvIyFg2Hw`->g5ovJOgE5%~j6}HtvzenpT+XEb$K&lmjCi!vh}7~-5H?GV z?#EsK6$tjDQTu+2s8wQa6FJ-&LXH~&`-%aw!_I?rbcVqI?qVx9Y@*sx<Nzf0t=obV z$ORrxJwcKrh??tgGfHA6gpeWz6N#D$V<+GM0W1+YWTguYQMo(f8aFzshG~c)#Q{6- zPe*6ij<}JE-oJVG!kruUU|T8#qJD=+(iF1+h8W<;WH@8zxF=9GO3ZDx8g&<FaMunA zZ3<Q)^AV++9j113Q>z(N2-~O6I7_$gae)A3G$cY?cumCLwx7WOZQ$F`xZC3@S67t^ z&?LTBelQ~QOcdV)VPvm)6#=}$YextXes&3nsxc)1qQW(8$R0OHs8qT@m|H!B?Q=uT z3@SFUZ8F>zA4kT*C^#YKX<c9*Um3!K`06koUl6I-jSmX^&lukAE?_`E)NJLcJmFT_ z4U;B<5FiHZJUW9L+aQ$WlMsl*#89}|q*&5Kt}FtOFyDn51W}DE16MU5TMnGr6SCs~ z3i{ibGfwYE|6^gIoU4Qc>{h!Q#5O`cRSDQm(|i>J7yL#hsqk=;1i`aR5=7$(Sl@SY z+%h=7SC}e)Pi*fi$~jY%Cn+23#g>1koQuakE>v7R;^cUsrh4ViKWAWmV=oFcU5~Ar z{qnQX=C=OrcUy+S6?0qHhwe^ZdpQzxWIG2<r2Na2WPEOaC`c5o1Gr^1y}d1?Z1GT; zUHVacXj`}L-2VCN_U)TDZ{2Q@kXLnQ<Tp=ymYw(SxFuXHLKj|*`1<m-rb|D6zP?ML zC@N4ma=$lo_;Walb-K#La^2&OE`8U=jb$a3*^XBu#-pzKmy~QA87)8E{qd~h*NCf* z&W`XpdS()3EJ_z8j2MX;ix`a%MxBj_6K@d*O^1vgT|2fGq6X|M-e(1xZ2kDOyKdci zxnmBT#U1g5d-GN>cjmtjA?Z!6sgc!G$*R=BY7)c_u~+NBtUNwwxQ==;gZ_y~j~=TN z`uO;6S!SuT++{ghyV2PrTIp&XcZDYBE(?LFsUI<v2{->A8ae`o2U55C2W|&_L*M7T zNS~DvXo!K+IzUcF<twnR@<@iC^D)|V7bAm}ess;o4SGMl*s{-WZ)3xu!b7~z=UhVr z`bB&G30aQF-02*rV8Eb8YR`naj}iL=MvoH1K3s#8%(E^MN>ju)ZM%;WV*v+_5#8Rf zos`VCE*AcnVylyR&^S&ZpgXl%Y6s^h<W?KyS989PkC#QxLTGcq@sq&hI3kQKyT4aB zICx~)vSWk&VAB}J{7+Uo2c6Y_`I0d^*zo*@t%ar*wD*g$yJ??nM^qHdW&8Q+JTGo( z`HrNHOhs(L+|1OM^@F`QmDN{WJ&@8XVD-z`ecMAXtExJspJGWUt>=>Uuno-Ks_H(b za67A4#_lzVkE^DdIncoAW0yE_t*EVKWn0@YDCz(QTSSGY8^gqvg_XRD4qEpzK2KR3 zRT{%zvRIVTk&@P$Eur)e&5M;pi#Bc)MN4Alqu3WwYlEnfxiqzhJ)|74zAR&zt5$@Z zWb{a(-|Nnnb!Bs|+d|i0t!Q=9+AFS%v)+C(DbXQhMONnzkfo@;Xt6(AsMr@<7g&-O z3ro{_mNG{?db*x}-nnB7$~6Y`b<`MJN<0-wi}P%`l0r)IYi^o2VWV#Ju_ec&dhXn9 zb)%Ecc|=#y-Zakt0GKc6FW}{UlXh?ZW+lwZ6+r)>f2ALFTK`<tQdZd_0{2Tx8Na0W z(PIU}n}HZLZ+cM?J)#z<)SM(_PazLjd`Y_GSM@%YE~gRMo))$s$eY0Zww9dnmEMmt zGQu-~ezM0j-uX9i*1ZWv4f+ZegEg0+uE}b$ho76h&bBEs-!rionfWKIroMtGj0j)J zCNm~VNNA$4?!@2&<LhY&M1n(W9LOnzoqSgu2Z7_z#Fyk*8pL9L6gvLT@41#~0KvK% zc`8+MLb><R??tv)5tX-$pvn;=pfwcTlgkRF(JDEc%1Flf{ioTxvc!nV3X(~uBCD)e z#;V<pVp01FJ`f|`)DckO64p6;2y5LboOQeMxv8&m*Vbbk0}#nC?R|^_)?+W>!@wD% z-qcYMJ){(WN$^t~mxhT+$8_XZc9+w#?^7ubmnwwa)WBj3P3&JXpCs^s_*WwFHt_a& zC`uq2)Um)$gVda0*|WA<ZZ4hFG8?%0)#CKsd0LGZRJS7-m93Nm;$SFyiWR6izpPG7 z2Omd}?HDz05rkfmfJ>26QI#WucO!brii+;)?}Hs1*>0L)d`)ZG+w#Eo1S8!Yu5IZo zp_*(a?Va_g*^U@_6FVf^jSBejdVA)n+xjhuUEWuEA7f5b5aksDO8SyvF_|xlbcsZl z<WBjri46k8aJ#=`PqyvX+U~>E4y;jQZ-dCBpW`srtxK>&?9}M4<<(A4FGOF-ef%TU zsI$~ZRU!Bly^poson^>4>z0;5=ZuX+#B%n9!oA2{gbI14z-+3t0&rGsz>Q;jf2{BS z*|!R9?sF+?8UX+LzkfCGhvhEWA+wL|Aqu{o__GK*n4wQ!1tFu0G&;?3k8kq^@`&k; z9{Frd51p}t)YPM|`4{?!jLy<G9m<P~)?-hs#5VIGa4VCF|JnTC@M+wgSVG9Sr3B1d zhLH8Je7ogHvfuKAoU#)1s-HVa(U&oZjeyJiAY}=;vX+vO)yl+pzp{|eTaI!&l!yGR zmEfS<c}<dz{`oHol;7R)&kgtDl-&gJy80G0=@cPfEcU?tk{GY#4cr7`*AGw2e1*e* z`p*6O$lbdal6kdnBzm6%=q9eI2<sEGjvL(M5x2QSor~Nh)@2>tVk3q+QS1!5E_TDm znJ;mj8dr(cxW`>CBGnzFsz_bn>Uu<-$Li`e`2lCS!8MfHi=sOB(5u$CO{hLQc#!k- zgG;E%$A%3U@_|jkbzeaZvH2X^(H-tHHG*s&TwpM`&CsjGHWmhBH___yN1crY0}4@h zO*CV0s7tgSrFl6Ld~?NAj978vC6G`eNk~#sl9r5QB`0|)NKs0-#07zKNN5k72!{2> z6&q8pt@}VJjI}R?asKUrqky(a7_@!q-VbgAcORVhp?3xFP4Y0;&Dk6E@b;ZM{Z0ki zCNt31(c`SZHi{+xutO34ir?}W@9w_}K~<nLxOn2Vhj8S4;k1VsqJC-Pm%U4>FNbHT y#k#IthLlTDKbq26*zJG#d_=lx`p%v%mCb_}GUZbKKk51`9pCiAwN8nB%=XPqG8yUs diff --git a/src/parser.nim b/src/parser.nim index aa0f8b2..f132dea 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -231,7 +231,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = replies: js{"reply_count"}.getInt, retweets: js{"retweet_count"}.getInt, likes: js{"favorite_count"}.getInt, - quotes: js{"quote_count"}.getInt + quotes: js{"quote_count"}.getInt, + views: js{"views_count"}.getInt ) ) @@ -339,6 +340,9 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = result.id = js{"rest_id"}.getId result.user = parseGraphUser(js{"core"}) + with count, js{"views", "count"}: + result.stats.views = count.getStr("0").parseInt + with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: result.expandNoteTweetEntities(noteTweet) diff --git a/src/types.nim b/src/types.nim index 55d990d..5a08bb7 100644 --- a/src/types.nim +++ b/src/types.nim @@ -203,6 +203,7 @@ type retweets*: int likes*: int quotes*: int + views*: int Tweet* = ref object id*: int64 diff --git a/src/views/general.nim b/src/views/general.nim index 5ba40a3..0571aa7 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -53,7 +53,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; buildHtml(head): link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") - link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) @@ -119,7 +119,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; # this is last so images are also preloaded # if this is done earlier, Chrome only preloads one image for some reason link(rel="preload", type="font/woff2", `as`="font", - href="/fonts/fontello.woff2?21002321", crossorigin="anonymous") + href="/fonts/fontello.woff2?61663884", crossorigin="anonymous") proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 34dcd4c..6d76755 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -184,6 +184,8 @@ proc renderStats(stats: TweetStats; views: string): VNode = span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) span(class="tweet-stat"): icon "quote", formatStat(stats.quotes) span(class="tweet-stat"): icon "heart", formatStat(stats.likes) + if stats.views > 0: + span(class="tweet-stat"): icon "views", formatStat(stats.views) if views.len > 0: span(class="tweet-stat"): icon "play", insertSep(views, ',') From 886f2d2a4540e238c3ff71b7c2341aba159c3f8c Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 11:00:38 +0100 Subject: [PATCH 142/309] Bump API versions, use more SessionAwareUrls --- src/api.nim | 41 ++++++++++++++---- src/consts.nim | 64 ++++++++++++++++++---------- src/experimental/parser/graphql.nim | 32 +++++++++++--- src/experimental/types/graphuser.nim | 30 +++++++++++-- src/parser.nim | 36 +++++++++------- 5 files changed, 145 insertions(+), 58 deletions(-) diff --git a/src/api.nim b/src/api.nim index c0efa58..aeb0f17 100644 --- a/src/api.nim +++ b/src/api.nim @@ -7,12 +7,39 @@ import experimental/parser as newParser proc mediaUrl(id: string; cursor: string): SessionAwareUrl = let cookieVariables = userMediaVariables % [id, cursor] - oauthVariables = userTweetsVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] result = SessionAwareUrl( cookieUrl: graphUserMedia ? {"variables": cookieVariables, "features": gqlFeatures}, oauthUrl: graphUserMediaV2 ? {"variables": oauthVariables, "features": gqlFeatures} ) +proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = userTweetsVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphUserTweets ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}, + oauthUrl: graphUserTweetsV2 ? {"variables": oauthVariables, "features": gqlFeatures} + ) + +proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = userTweetsAndRepliesVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphUserTweetsAndReplies ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}, + oauthUrl: graphUserTweetsAndRepliesV2 ? {"variables": oauthVariables, "features": gqlFeatures} + ) + +proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = tweetDetailVariables % [id, cursor] + oauthVariables = tweetVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphTweetDetail ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": tweetDetailFieldToggles}, + oauthUrl: graphTweet ? {"variables": oauthVariables, "features": gqlFeatures} + ) + proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return let @@ -33,13 +60,11 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = userTweetsVariables % [id, cursor] - params = {"variables": variables, "features": gqlFeatures} js = case kind of TimelineKind.tweets: - await fetch(graphUserTweets ? params, Api.userTweets) + await fetch(userTweetsUrl(id, cursor), Api.userTweets) of TimelineKind.replies: - await fetch(graphUserTweetsAndReplies ? params, Api.userTweetsAndReplies) + await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) of TimelineKind.media: await fetch(mediaUrl(id, cursor), Api.userMedia) result = parseGraphTimeline(js, after) @@ -48,7 +73,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = listTweetsVariables % [id, cursor] + variables = restIdVariables % [id, cursor] params = {"variables": variables, "features": gqlFeatures} js = await fetch(graphListTweets ? params, Api.listTweets) result = parseGraphTimeline(js, after).tweets @@ -94,9 +119,7 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = tweetVariables % [id, cursor] - params = {"variables": variables, "features": gqlFeatures} - js = await fetch(graphTweet ? params, Api.tweetDetail) + js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) result = parseGraphConversation(js, id) proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = diff --git a/src/consts.nim b/src/consts.nim index c8ae8d2..2623484 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -9,16 +9,19 @@ const graphUser* = gql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" - graphUserTweets* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2" - graphUserTweetsAndReplies* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweetsV2* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2" + graphUserTweetsAndRepliesV2* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" + graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphUserMediaV2* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2" + graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" - graphSearchTimeline* = gql / "KI9jCXUx3Ymt-hDKLOZb9Q/SearchTimeline" - graphListById* = gql / "oygmAig8kjn0pKsx_bUadQ/ListByRestId" - graphListBySlug* = gql / "88GTz-IPPWLn1EiU8XoNVg/ListBySlug" - graphListMembers* = gql / "kSmxeqEeelqdHSR7jMnb_w/ListMembers" + graphSearchTimeline* = gql / "7r8ibjHuK3MWUyzkzHNMYQ/SearchTimeline" + graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" + graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" + graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphListTweets* = gql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" gqlFeatures* = """{ @@ -96,24 +99,20 @@ const "withV2Timeline": true }""".replace(" ", "").replace("\n", "") -# oldUserTweetsVariables* = """{ -# "userId": "$1", $2 -# "count": 20, -# "includePromotedContent": false, -# "withDownvotePerspective": false, -# "withReactionsMetadata": false, -# "withReactionsPerspective": false, -# "withVoice": false, -# "withV2Timeline": true -# } -# """ + tweetDetailVariables* = """{ + "focalTweetId": "$1", + $2 + "referrer": "profile", + "with_rux_injections": false, + "rankingMode": "Relevance", + "includePromotedContent": true, + "withCommunity": true, + "withQuickPromoteEligibilityTweetFields": true, + "withBirdwatchNotes": true, + "withVoice": true +}""".replace(" ", "").replace("\n", "") - userTweetsVariables* = """{ - "rest_id": "$1", $2 - "count": 20 -}""" - - listTweetsVariables* = """{ + restIdVariables* = """{ "rest_id": "$1", $2 "count": 20 }""" @@ -126,3 +125,22 @@ const "withBirdwatchNotes": false, "withVoice": true }""".replace(" ", "").replace("\n", "") + + userTweetsVariables* = """{ + "userId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withQuickPromoteEligibilityTweetFields": true, + "withVoice": true +}""".replace(" ", "").replace("\n", "") + + userTweetsAndRepliesVariables* = """{ + "userId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withCommunity": true, + "withVoice": true +}""".replace(" ", "").replace("\n", "") + + fieldToggles* = """{"withArticlePlainText":false}""" + tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 69837ab..045a5d6 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -1,21 +1,39 @@ -import options +import options, strutils import jsony import user, ../types/[graphuser, graphlistmembers] from ../../types import User, VerifiedType, Result, Query, QueryKind +proc parseUserResult*(userResult: UserResult): User = + result = userResult.legacy + + if result.verifiedType == none and userResult.isBlueVerified: + result.verifiedType = blue + + if result.username.len == 0 and userResult.core.screenName.len > 0: + result.id = userResult.restId + result.username = userResult.core.screenName + result.fullname = userResult.core.name + result.userPic = userResult.avatar.imageUrl.replace("_normal", "") + + if userResult.verification.isSome: + let v = userResult.verification.get + if v.verifiedType != VerifiedType.none: + result.verifiedType = v.verifiedType + + if userResult.profileBio.isSome: + result.bio = userResult.profileBio.get.description + proc parseGraphUser*(json: string): User = if json.len == 0 or json[0] != '{': return let raw = json.fromJson(GraphUser) + let userResult = raw.data.userResult.result - if raw.data.userResult.result.unavailableReason.get("") == "Suspended": + if userResult.unavailableReason.get("") == "Suspended": return User(suspended: true) - result = raw.data.userResult.result.legacy - result.id = raw.data.userResult.result.restId - if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified: - result.verifiedType = blue + result = parseUserResult(userResult) proc parseGraphListMembers*(json, cursor: string): Result[User] = result = Result[User]( @@ -31,7 +49,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] = of TimelineTimelineItem: let userResult = entry.content.itemContent.userResults.result if userResult.restId.len > 0: - result.content.add userResult.legacy + result.content.add parseUserResult(userResult) of TimelineTimelineCursor: if entry.content.cursorType == "Bottom": result.bottom = entry.content.value diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index 08100f9..d732b4e 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -1,5 +1,5 @@ -import options -from ../../types import User +import options, strutils +from ../../types import User, VerifiedType type GraphUser* = object @@ -8,8 +8,32 @@ type UserData* = object result*: UserResult - UserResult = object + UserCore* = object + name*: string + screenName*: string + createdAt*: string + + UserBio* = object + description*: string + + UserAvatar* = object + imageUrl*: string + + Verification* = object + verifiedType*: VerifiedType + + UserResult* = object legacy*: User restId*: string isBlueVerified*: bool unavailableReason*: Option[string] + core*: UserCore + avatar*: UserAvatar + profileBio*: Option[UserBio] + verification*: Option[Verification] + +proc enumHook*(s: string; v: var VerifiedType) = + v = try: + parseEnum[VerifiedType](s) + except: + VerifiedType.none diff --git a/src/parser.nim b/src/parser.nim index f132dea..c4ccab0 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -42,16 +42,16 @@ proc parseGraphUser(js: JsonNode): User = result = parseUser(user{"legacy"}, user{"rest_id"}.getStr) # fallback to support UserMedia/recent GraphQL updates - if result.username.len == 0 and user{"core", "screen_name"}.notNull: + if result.username.len == 0: result.username = user{"core", "screen_name"}.getStr result.fullname = user{"core", "name"}.getStr result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") if user{"is_blue_verified"}.getBool(false): result.verifiedType = blue - elif user{"verification", "verified_type"}.notNull: - let verifiedType = user{"verification", "verified_type"}.getStr("None") - result.verifiedType = parseEnum[VerifiedType](verifiedType) + + with verifiedType, user{"verification", "verified_type"}: + result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -372,10 +372,10 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet, false) -proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversation = +proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) - let + v2 = js{"data", "timeline_response"}.notNull rootKey = if v2: "timeline_response" else: "threaded_conversation_with_injections_v2" contentKey = if v2: "content" else: "itemContent" resultKey = if v2: "tweetResult" else: "tweet_results" @@ -385,7 +385,8 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati return for i in instructions: - if i{"__typename"}.getStr == "TimelineAddEntries": + let instrType = i{"__typename"}.getStr(i{"type"}.getStr) + if instrType == "TimelineAddEntries": for e in i{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): @@ -421,20 +422,23 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string; v2=true): Conversati result.replies.bottom = e{"content", contentKey, "value"}.getStr proc extractTweetsFromEntry*(e: JsonNode; entryId: string): seq[Tweet] = - if e{"content", "items"}.notNull: - for item in e{"content", "items"}: - with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: - var tweet = parseGraphTweet(tweetResult, false) - if not tweet.available: - tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) - result.add tweet - return + var tweetResult = e{"content", "itemContent", "tweet_results", "result"} + if tweetResult.isNull: + tweetResult = e{"content", "content", "tweetResult", "result"} - with tweetResult, e{"content", "content", "tweetResult", "result"}: + if tweetResult.notNull: var tweet = parseGraphTweet(tweetResult, false) if not tweet.available: tweet.id = parseBiggestInt(entryId.getId()) result.add tweet + return + + for item in e{"content", "items"}: + with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: + var tweet = parseGraphTweet(tweetResult, false) + if not tweet.available: + tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) From 6b655cddd803fb1efc424b70d8f7be760b6c7c60 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 11:01:20 +0100 Subject: [PATCH 143/309] Cleanup --- src/auth.nim | 17 +---------------- src/experimental/parser/user.nim | 18 ------------------ src/experimental/types/timeline.nim | 23 ----------------------- 3 files changed, 1 insertion(+), 57 deletions(-) delete mode 100644 src/experimental/types/timeline.nim diff --git a/src/auth.nim b/src/auth.nim index 6c52918..734b43e 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -7,20 +7,6 @@ import experimental/parser/session const maxConcurrentReqs = 2 hourInSeconds = 60 * 60 - apiMaxReqs: Table[Api, int] = { - Api.search: 50, - Api.tweetDetail: 500, - Api.userTweets: 500, - Api.userTweetsAndReplies: 500, - Api.userMedia: 500, - Api.userRestId: 500, - Api.userScreenName: 500, - Api.tweetResult: 500, - Api.list: 500, - Api.listTweets: 500, - Api.listMembers: 500, - Api.listBySlug: 500 - }.toTable var sessionPool: seq[Session] @@ -71,8 +57,7 @@ proc getSessionPoolHealth*(): JsonNode = for api in session.apis.keys: let apiStatus = session.apis[api] - limit = if apiStatus.limit > 0: apiStatus.limit else: apiMaxReqs.getOrDefault(api, 0) - reqs = limit - apiStatus.remaining + reqs = apiStatus.limit - apiStatus.remaining # no requests made with this session and endpoint since the limit reset if apiStatus.reset < now: diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 07e0477..498757a 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -72,21 +72,3 @@ proc parseHook*(s: string; i: var int; v: var User) = var u: RawUser parseHook(s, i, u) v = toUser u - -proc parseUser*(json: string; username=""): User = - handleErrors: - case error.code - of suspended: return User(username: username, suspended: true) - of userNotFound: return - else: echo "[error - parseUser]: ", error - - result = json.fromJson(User) - -proc parseUsers*(json: string; after=""): Result[User] = - result = Result[User](beginning: after.len == 0) - - # starting with '{' means it's an error - if json[0] == '[': - let raw = json.fromJson(seq[RawUser]) - for user in raw: - result.content.add user.toUser diff --git a/src/experimental/types/timeline.nim b/src/experimental/types/timeline.nim deleted file mode 100644 index 5ce6d9f..0000000 --- a/src/experimental/types/timeline.nim +++ /dev/null @@ -1,23 +0,0 @@ -import std/tables -from ../../types import User - -type - Search* = object - globalObjects*: GlobalObjects - timeline*: Timeline - - GlobalObjects = object - users*: Table[string, User] - - Timeline = object - instructions*: seq[Instructions] - - Instructions = object - addEntries*: tuple[entries: seq[Entry]] - - Entry = object - entryId*: string - content*: tuple[operation: Operation] - - Operation = object - cursor*: tuple[value, cursorType: string] From e8de18317ec072e47a56b42dff44f7136b20117e Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 11:21:21 +0100 Subject: [PATCH 144/309] Fix broken pinned tweet parsing --- src/parser.nim | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index c4ccab0..614ab57 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -385,7 +385,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = return for i in instructions: - let instrType = i{"__typename"}.getStr(i{"type"}.getStr) + let instrType = i{"type"}.getStr(i{"__typename"}.getStr) if instrType == "TimelineAddEntries": for e in i{"entries"}: let entryId = e{"entryId"}.getStr @@ -421,7 +421,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = elif entryId.startsWith("cursor-bottom"): result.replies.bottom = e{"content", contentKey, "value"}.getStr -proc extractTweetsFromEntry*(e: JsonNode; entryId: string): seq[Tweet] = +proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = var tweetResult = e{"content", "itemContent", "tweet_results", "result"} if tweetResult.isNull: tweetResult = e{"content", "content", "tweetResult", "result"} @@ -429,7 +429,7 @@ proc extractTweetsFromEntry*(e: JsonNode; entryId: string): seq[Tweet] = if tweetResult.notNull: var tweet = parseGraphTweet(tweetResult, false) if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + tweet.id = parseBiggestInt(e.getEntryId()) result.add tweet return @@ -469,7 +469,7 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = for e in i{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): - for tweet in extractTweetsFromEntry(e, entryId): + for tweet in extractTweetsFromEntry(e): result.tweets.content.add tweet elif "-conversation-" in entryId or entryId.startsWith("homeConversation"): let (thread, self) = parseGraphThread(e) @@ -477,15 +477,14 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = elif entryId.startsWith("cursor-bottom"): result.tweets.bottom = e{"content", "value"}.getStr - if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry": - with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}: - let tweet = parseGraphTweet(tweetResult, false) - tweet.pinned = true - if not tweet.available and tweet.tombstone.len == 0: - let entryId = i{"entry", "entryId"}.getEntryId - if entryId.len > 0: - tweet.id = parseBiggestInt(entryId) - result.pinned = some tweet + if after.len == 0: + let instrType = i{"type"}.getStr(i{"__typename"}.getStr) + if instrType == "TimelinePinEntry": + let tweets = extractTweetsFromEntry(i{"entry"}) + if tweets.len > 0: + var tweet = tweets[0] + tweet.pinned = true + result.pinned = some tweet proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = result = @[] @@ -523,7 +522,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = for e in i{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): - for t in extractTweetsFromEntry(e, entryId): + for t in extractTweetsFromEntry(e): let photo = extractGalleryPhoto(t) if photo.url.len > 0: result.add photo From 824a7e346a730a3eeb87a945e8268b3e40867f0d Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 17 Nov 2025 12:08:44 +0100 Subject: [PATCH 145/309] Fix rss icon tag --- src/views/general.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/general.nim b/src/views/general.nim index 0571aa7..0091c74 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -30,7 +30,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = tdiv(class="nav-item right"): icon "search", title="Search", href="/search" if cfg.enableRss and rss.len > 0: - icon "rss-feed", title="RSS Feed", href=rss + icon "rss", title="RSS Feed", href=rss icon "bird", title="Open in Twitter", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp icon "info", title="About", href="/about" From 78d788b27f977ec8f7d6c91d9742c1b8343d08bf Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Wed, 19 Nov 2025 07:33:32 +0100 Subject: [PATCH 146/309] Fix verified parsing for oauth endpoints --- src/parser.nim | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/parser.nim b/src/parser.nim index 614ab57..8712a5f 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -21,11 +21,16 @@ proc parseUser(js: JsonNode; id=""): User = tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, - verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")), protected: js{"protected"}.getBool, joinDate: js{"created_at"}.getTime ) + if js{"is_blue_verified"}.getBool(false): + result.verifiedType = blue + + with verifiedType, js{"verified_type"}: + result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + result.expandUserEntities(js) proc parseGraphUser(js: JsonNode): User = From b0d9c1d51a4039cecbb58ab0180883baf5325d59 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 22 Nov 2025 21:21:10 +0100 Subject: [PATCH 147/309] Update endpoints, fix parser, remove quotes stat --- src/api.nim | 58 +++++---- src/consts.nim | 35 +++-- src/parser.nim | 308 ++++++++++++++++++++++++++------------------ src/parserutils.nim | 53 ++++---- src/types.nim | 2 - src/views/tweet.nim | 13 +- 6 files changed, 269 insertions(+), 200 deletions(-) diff --git a/src/api.nim b/src/api.nim index aeb0f17..ef3a0f9 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,16 +1,23 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, httpclient, uri, strutils, sequtils, sugar +import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables import packedjson import types, query, formatters, consts, apiutils, parser import experimental/parser as newParser +# Helper to generate params object for GraphQL requests +proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = + result.add ("variables", variables) + result.add ("features", gqlFeatures) + if fieldToggles.len > 0: + result.add ("fieldToggles", fieldToggles) + proc mediaUrl(id: string; cursor: string): SessionAwareUrl = let cookieVariables = userMediaVariables % [id, cursor] oauthVariables = restIdVariables % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphUserMedia ? {"variables": cookieVariables, "features": gqlFeatures}, - oauthUrl: graphUserMediaV2 ? {"variables": oauthVariables, "features": gqlFeatures} + cookieUrl: graphUserMedia ? genParams(cookieVariables), + oauthUrl: graphUserMediaV2 ? genParams(oauthVariables) ) proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = @@ -18,17 +25,19 @@ proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = cookieVariables = userTweetsVariables % [id, cursor] oauthVariables = restIdVariables % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphUserTweets ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}, - oauthUrl: graphUserTweetsV2 ? {"variables": oauthVariables, "features": gqlFeatures} + # cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles), + oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables) ) + # might change this in the future pending testing + result.cookieUrl = result.oauthUrl proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = let cookieVariables = userTweetsAndRepliesVariables % [id, cursor] oauthVariables = restIdVariables % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphUserTweetsAndReplies ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": fieldToggles}, - oauthUrl: graphUserTweetsAndRepliesV2 ? {"variables": oauthVariables, "features": gqlFeatures} + cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles), + oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables) ) proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = @@ -36,24 +45,22 @@ proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = cookieVariables = tweetDetailVariables % [id, cursor] oauthVariables = tweetVariables % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphTweetDetail ? {"variables": cookieVariables, "features": gqlFeatures, "fieldToggles": tweetDetailFieldToggles}, - oauthUrl: graphTweet ? {"variables": oauthVariables, "features": gqlFeatures} + cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles), + oauthUrl: graphTweet ? genParams(oauthVariables) ) proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return let - variables = """{"screen_name": "$1"}""" % username - params = {"variables": variables, "features": gqlFeatures} - js = await fetchRaw(graphUser ? params, Api.userScreenName) + url = graphUser ? genParams("""{"screen_name": "$1"}""" % username) + js = await fetchRaw(url, Api.userScreenName) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = if id.len == 0 or id.any(c => not c.isDigit): return let - variables = """{"rest_id": "$1"}""" % id - params = {"variables": variables, "features": gqlFeatures} - js = await fetchRaw(graphUserById ? params, Api.userRestId) + url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id) + js = await fetchRaw(url, Api.userRestId) result = parseGraphUser(js) proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = @@ -73,23 +80,18 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = restIdVariables % [id, cursor] - params = {"variables": variables, "features": gqlFeatures} - js = await fetch(graphListTweets ? params, Api.listTweets) - result = parseGraphTimeline(js, after).tweets + url = graphListTweets ? genParams(restIdVariables % [id, cursor]) + result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list} - params = {"variables": $variables, "features": gqlFeatures} - url = graphListBySlug ? params + url = graphListBySlug ? genParams($variables) result = parseGraphList(await fetch(url, Api.listBySlug)) proc getGraphList*(id: string): Future[List] {.async.} = let - variables = """{"listId": "$1"}""" % id - params = {"variables": variables, "features": gqlFeatures} - url = graphListById ? params + url = graphListById ? genParams("""{"listId": "$1"}""" % id) result = parseGraphList(await fetch(url, Api.list)) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = @@ -104,7 +106,7 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} } if after.len > 0: variables["cursor"] = % after - let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} + let url = graphListMembers ? genParams($variables) result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = @@ -139,6 +141,7 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = var variables = %*{ "rawQuery": q, + "query_source": "typedQuery", "count": 20, "product": "Latest", "withDownvotePerspective": false, @@ -147,7 +150,7 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = } if after.len > 0: variables["cursor"] = % after - let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} + let url = graphSearchTimeline ? genParams($variables) result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) result.query = query @@ -158,6 +161,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} var variables = %*{ "rawQuery": query.text, + "query_source": "typedQuery", "count": 20, "product": "People", "withDownvotePerspective": false, @@ -168,7 +172,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} variables["cursor"] = % after result.beginning = false - let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} + let url = graphSearchTimeline ? genParams($variables) result = parseGraphSearch[User](await fetch(url, Api.search), after) result.query = query diff --git a/src/consts.nim b/src/consts.nim index 2623484..792a519 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -7,26 +7,29 @@ const gql = parseUri("https://api.x.com") / "graphql" - graphUser* = gql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" - graphUserById* = gql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" - graphUserTweetsV2* = gql / "JLApJKFY0MxGTzCoK6ps8Q/UserWithProfileTweetsQueryV2" - graphUserTweetsAndRepliesV2* = gql / "Y86LQY7KMvxn5tu3hFTyPg/UserWithProfileTweetsAndRepliesQueryV2" + graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" + graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" + graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" + graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" - graphUserMediaV2* = gql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2" - graphTweet* = gql / "Vorskcd2tZ-tc4Gx3zbk4Q/ConversationTimelineV2" + graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" + graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" - graphTweetResult* = gql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" - graphSearchTimeline* = gql / "7r8ibjHuK3MWUyzkzHNMYQ/SearchTimeline" + graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" + graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" - graphListTweets* = gql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" + graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" gqlFeatures* = """{ + "android_ad_formats_media_component_render_overlay_enabled": false, "android_graphql_skip_api_media_color_palette": false, + "android_professional_link_spotlight_display_enabled": false, "blue_business_profile_image_shape_enabled": false, + "commerce_android_shop_module_enabled": false, "creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": true, @@ -36,8 +39,9 @@ const "interactive_text_enabled": false, "longform_notetweets_consumption_enabled": true, "longform_notetweets_inline_media_enabled": true, - "longform_notetweets_richtext_consumption_enabled": true, "longform_notetweets_rich_text_read_enabled": true, + "longform_notetweets_richtext_consumption_enabled": true, + "mobile_app_spotlight_module_enabled": false, "responsive_web_edit_tweet_api_enabled": true, "responsive_web_enhance_cards_enabled": false, "responsive_web_graphql_exclude_directive_enabled": true, @@ -46,6 +50,7 @@ const "responsive_web_media_download_video_enabled": false, "responsive_web_text_conversations_enabled": false, "responsive_web_twitter_article_tweet_consumption_enabled": true, + "unified_cards_destination_url_params_enabled": false, "responsive_web_twitter_blue_verified_badge_is_enabled": true, "rweb_lists_timeline_redesign_enabled": true, "spaces_2022_h2_clipping": true, @@ -86,11 +91,17 @@ const "payments_enabled": false, "responsive_web_profile_redirect_enabled": false, "responsive_web_grok_show_grok_translated_post": false, - "responsive_web_grok_community_note_auto_translation_is_enabled": false + "responsive_web_grok_community_note_auto_translation_is_enabled": false, + "profile_label_improvements_pcf_label_in_profile_enabled": false, + "grok_android_analyze_trend_fetch_enabled": false, + "grok_translations_community_note_auto_translation_is_enabled": false, + "grok_translations_post_auto_translation_is_enabled": false, + "grok_translations_community_note_translation_is_enabled": false, + "grok_translations_timeline_user_bio_auto_translation_is_enabled": false }""".replace(" ", "").replace("\n", "") tweetVariables* = """{ - "focalTweetId": "$1", + "postId": "$1", $2 "includeHasBirdwatchNotes": false, "includePromotedContent": false, diff --git a/src/parser.nim b/src/parser.nim index 8712a5f..700e896 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -1,10 +1,10 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, options, times, math +import strutils, options, times, math, tables import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard -proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet +proc parseGraphTweet(js: JsonNode): Tweet proc parseUser(js: JsonNode; id=""): User = if js.isNull: return @@ -46,6 +46,9 @@ proc parseGraphUser(js: JsonNode): User = result = parseUser(user{"legacy"}, user{"rest_id"}.getStr) + if result.verifiedType == none and user{"is_blue_verified"}.getBool(false): + result.verifiedType = blue + # fallback to support UserMedia/recent GraphQL updates if result.username.len == 0: result.username = user{"core", "screen_name"}.getStr @@ -95,16 +98,24 @@ proc parsePoll(js: JsonNode): Poll = result.leader = result.values.find(max(result.values)) result.votes = result.values.sum -proc parseGif(js: JsonNode): Gif = - result = Gif( - url: js{"video_info", "variants"}[0]{"url"}.getImageStr, - thumb: js{"media_url_https"}.getImageStr - ) +proc parseVideoVariants(variants: JsonNode): seq[VideoVariant] = + result = @[] + for v in variants: + let + url = v{"url"}.getStr + contentType = parseEnum[VideoType](v{"content_type"}.getStr("video/mp4")) + bitrate = v{"bit_rate"}.getInt(v{"bitrate"}.getInt(0)) + + result.add VideoVariant( + contentType: contentType, + bitrate: bitrate, + url: url, + resolution: if contentType == mp4: getMp4Resolution(url) else: 0 + ) proc parseVideo(js: JsonNode): Video = result = Video( thumb: js{"media_url_https"}.getImageStr, - views: getVideoViewCount(js), available: true, title: js{"ext_alt_text"}.getStr, durationMs: js{"video_info", "duration_millis"}.getInt @@ -121,17 +132,62 @@ proc parseVideo(js: JsonNode): Video = with description, js{"additional_media_info", "description"}: result.description = description.getStr - for v in js{"video_info", "variants"}: - let - contentType = parseEnum[VideoType](v{"content_type"}.getStr("summary")) - url = v{"url"}.getStr + result.variants = parseVideoVariants(js{"video_info", "variants"}) - result.variants.add VideoVariant( - contentType: contentType, - bitrate: v{"bitrate"}.getInt, - url: url, - resolution: if contentType == mp4: getMp4Resolution(url) else: 0 - ) +proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = + with jsMedia, js{"extended_entities", "media"}: + for m in jsMedia: + case m.getTypeName: + of "photo": + result.photos.add m{"media_url_https"}.getImageStr + of "video": + result.video = some(parseVideo(m)) + with user, m{"additional_media_info", "source_user"}: + if user{"id"}.getInt > 0: + result.attribution = some(parseUser(user)) + else: + result.attribution = some(parseGraphUser(user)) + of "animated_gif": + result.gif = some Gif( + url: m{"video_info", "variants"}[0]{"url"}.getImageStr, + thumb: m{"media_url_https"}.getImageStr + ) + else: discard + + with url, m{"url"}: + if result.text.endsWith(url.getStr): + result.text.removeSuffix(url.getStr) + result.text = result.text.strip() + +proc parseMediaEntities(js: JsonNode; result: var Tweet) = + with mediaEntities, js{"media_entities"}: + for mediaEntity in mediaEntities: + with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: + case mediaInfo.getTypeName + of "ApiImage": + result.photos.add mediaInfo{"original_img_url"}.getImageStr + of "ApiVideo": + let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} + result.video = some Video( + available: status.getStr == "Available", + thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, + durationMs: mediaInfo{"duration_millis"}.getInt, + variants: parseVideoVariants(mediaInfo{"variants"}) + ) + of "ApiGif": + result.gif = some Gif( + url: mediaInfo{"variants"}[0]{"url"}.getImageStr, + thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr + ) + else: discard + + # Remove media URLs from text + with mediaList, js{"legacy", "entities", "media"}: + for url in mediaList: + let expandedUrl = url{"expanded_url"}.getStr + if result.text.endsWith(expandedUrl): + result.text.removeSuffix(expandedUrl) + result.text = result.text.strip() proc parsePromoVideo(js: JsonNode): Video = result = Video( @@ -223,12 +279,17 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = if js.isNull: return + + let time = + if js{"created_at"}.notNull: js{"created_at"}.getTime + else: js{"created_at_ms"}.getTimeFromMs + result = Tweet( id: js{"id_str"}.getId, threadId: js{"conversation_id_str"}.getId, replyId: js{"in_reply_to_status_id_str"}.getId, text: js{"full_text"}.getStr, - time: js{"created_at"}.getTime, + time: time, hasThread: js{"self_thread"}.notNull, available: true, user: User(id: js{"user_id_str"}.getStr), @@ -236,7 +297,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = replies: js{"reply_count"}.getInt, retweets: js{"retweet_count"}.getInt, likes: js{"favorite_count"}.getInt, - quotes: js{"quote_count"}.getInt, views: js{"views_count"}.getInt ) ) @@ -262,6 +322,12 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.retweet = some parseGraphTweet(rt) return + with reposts, js{"repostedStatusResults"}: + with rt, reposts{"result"}: + if "legacy" in rt: + result.retweet = some parseGraphTweet(rt) + return + if jsCard.kind != JNull: let name = jsCard{"name"}.getStr if "poll" in name: @@ -275,27 +341,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.card = some parseCard(jsCard, js{"entities", "urls"}) result.expandTweetEntities(js) - - with jsMedia, js{"extended_entities", "media"}: - for m in jsMedia: - case m{"type"}.getStr - of "photo": - result.photos.add m{"media_url_https"}.getImageStr - of "video": - result.video = some(parseVideo(m)) - with user, m{"additional_media_info", "source_user"}: - if user{"id"}.getInt > 0: - result.attribution = some(parseUser(user)) - else: - result.attribution = some(parseGraphUser(user)) - of "animated_gif": - result.gif = some(parseGif(m)) - else: discard - - with url, m{"url"}: - if result.text.endsWith(url.getStr): - result.text.removeSuffix(url.getStr) - result.text = result.text.strip() + parseLegacyMediaEntities(js, result) with jsWithheld, js{"withheld_in_countries"}: let withheldInCountries: seq[string] = @@ -311,95 +357,108 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.text.removeSuffix(" Learn more.") result.available = false -proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = +proc parseGraphTweet(js: JsonNode): Tweet = if js.kind == JNull: return Tweet() - case js{"__typename"}.getStr + case js.getTypeName: of "TweetUnavailable": return Tweet() of "TweetTombstone": - with text, js{"tombstone", "richText"}: - return Tweet(text: text.getTombstone) - with text, js{"tombstone", "text"}: + with text, select(js{"tombstone", "richText"}, js{"tombstone", "text"}): return Tweet(text: text.getTombstone) return Tweet() of "TweetPreviewDisplay": return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") of "TweetWithVisibilityResults": - return parseGraphTweet(js{"tweet"}, isLegacy) + return parseGraphTweet(js{"tweet"}) else: discard if not js.hasKey("legacy"): return Tweet() - var jsCard = copy(js{if isLegacy: "card" else: "tweet_card", "legacy"}) + var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) if jsCard.kind != JNull: - var values = newJObject() - for val in jsCard["binding_values"]: - values[val["key"].getStr] = val["value"] - jsCard["binding_values"] = values + let legacyCard = jsCard{"legacy"} + if legacyCard.kind != JNull: + let bindingArray = legacyCard{"binding_values"} + if bindingArray.kind == JArray: + var bindingObj: seq[(string, JsonNode)] + for item in bindingArray: + bindingObj.add((item{"key"}.getStr, item{"value"})) + # Create a new card object with flattened structure + jsCard = %*{ + "name": legacyCard{"name"}, + "url": legacyCard{"url"}, + "binding_values": %bindingObj + } result = parseTweet(js{"legacy"}, jsCard) result.id = js{"rest_id"}.getId result.user = parseGraphUser(js{"core"}) + if result.replyId == 0: + result.replyId = js{"reply_to_results", "rest_id"}.getId + with count, js{"views", "count"}: result.stats.views = count.getStr("0").parseInt with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: result.expandNoteTweetEntities(noteTweet) + parseMediaEntities(js, result) + if result.quote.isSome: - result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy)) + result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) + + with quoted, js{"quotedPostResults", "result"}: + result.quote = some(parseGraphTweet(quoted)) proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = - for t in js{"content", "items"}: - let entryId = t{"entryId"}.getStr + for t in ? js{"content", "items"}: + let entryId = t.getEntryId if "cursor-showmore" in entryId: let cursor = t{"item", "content", "value"} result.thread.cursor = cursor.getStr result.thread.hasMore = true elif "tweet" in entryId and "promoted" notin entryId: - let - isLegacy = t{"item"}.hasKey("itemContent") - (contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results") - else: ("content", "tweetResult") + with tweet, t.getTweetResult("item"): + result.thread.content.add parseGraphTweet(tweet) - with content, t{"item", contentKey}: - result.thread.content.add parseGraphTweet(content{resultKey, "result"}, isLegacy) - - if content{"tweetDisplayType"}.getStr == "SelfThread": + let tweetDisplayType = select( + t{"item", "content", "tweet_display_type"}, + t{"item", "itemContent", "tweetDisplayType"} + ) + if tweetDisplayType.getStr == "SelfThread": result.self = true proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: - result = parseGraphTweet(tweet, false) + result = parseGraphTweet(tweet) proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) - let - v2 = js{"data", "timeline_response"}.notNull - rootKey = if v2: "timeline_response" else: "threaded_conversation_with_injections_v2" - contentKey = if v2: "content" else: "itemContent" - resultKey = if v2: "tweetResult" else: "tweet_results" - let instructions = ? js{"data", rootKey, "instructions"} + let instructions = ? select( + js{"data", "timelineResponse", "instructions"}, + js{"data", "timeline_response", "instructions"}, + js{"data", "threaded_conversation_with_injections_v2", "instructions"} + ) if instructions.len == 0: return for i in instructions: - let instrType = i{"type"}.getStr(i{"__typename"}.getStr) - if instrType == "TimelineAddEntries": + if i.getTypeName == "TimelineAddEntries": for e in i{"entries"}: - let entryId = e{"entryId"}.getStr + let entryId = e.getEntryId if entryId.startsWith("tweet"): - with tweetResult, e{"content", contentKey, resultKey, "result"}: - let tweet = parseGraphTweet(tweetResult, not v2) + let tweetResult = getTweetResult(e) + if tweetResult.notNull: + let tweet = parseGraphTweet(tweetResult) if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + tweet.id = entryId.getId if $tweet.id == tweetId: result.tweet = tweet @@ -412,67 +471,64 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = elif thread.content.len > 0: result.replies.content.add thread elif entryId.startsWith("tombstone"): - let id = entryId.getId() - let tweet = Tweet( - id: parseBiggestInt(id), - available: false, - text: e{"content", contentKey, "tombstoneInfo", "richText"}.getTombstone - ) + let + content = select(e{"content", "content"}, e{"content", "itemContent"}) + tweet = Tweet( + id: entryId.getId, + available: false, + text: content{"tombstoneInfo", "richText"}.getTombstone + ) - if id == tweetId: + if $tweet.id == tweetId: result.tweet = tweet else: result.before.content.add tweet elif entryId.startsWith("cursor-bottom"): - result.replies.bottom = e{"content", contentKey, "value"}.getStr + var cursorValue = select( + e{"content", "content", "value"}, + e{"content", "itemContent", "value"} + ) + result.replies.bottom = cursorValue.getStr proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = - var tweetResult = e{"content", "itemContent", "tweet_results", "result"} - if tweetResult.isNull: - tweetResult = e{"content", "content", "tweetResult", "result"} - - if tweetResult.notNull: - var tweet = parseGraphTweet(tweetResult, false) + with tweetResult, getTweetResult(e): + var tweet = parseGraphTweet(tweetResult) if not tweet.available: - tweet.id = parseBiggestInt(e.getEntryId()) + tweet.id = e.getEntryId.getId result.add tweet return for item in e{"content", "items"}: - with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: - var tweet = parseGraphTweet(tweetResult, false) + with tweetResult, item.getTweetResult("item"): + var tweet = parseGraphTweet(tweetResult) if not tweet.available: - tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + tweet.id = item.getEntryId.getId result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) - let instructions = - if js{"data", "list"}.notNull: - ? js{"data", "list", "timeline_response", "timeline", "instructions"} - elif js{"data", "user"}.notNull: - ? js{"data", "user", "result", "timeline", "timeline", "instructions"} - else: - ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} - + let instructions = ? select( + js{"data", "list", "timeline_response", "timeline", "instructions"}, + js{"data", "user", "result", "timeline", "timeline", "instructions"}, + js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + ) if instructions.len == 0: return for i in instructions: - # TimelineAddToModule instruction is used by UserMedia if i{"moduleItems"}.notNull: for item in i{"moduleItems"}: - with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetResult, false) + with tweetResult, item.getTweetResult("item"): + let tweet = parseGraphTweet(tweetResult) if not tweet.available: - tweet.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + tweet.id = item.getEntryId.getId result.tweets.content.add tweet continue if i{"entries"}.notNull: for e in i{"entries"}: - let entryId = e{"entryId"}.getStr + let entryId = e.getEntryId if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): for tweet in extractTweetsFromEntry(e): result.tweets.content.add tweet @@ -483,8 +539,7 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result.tweets.bottom = e{"content", "value"}.getStr if after.len == 0: - let instrType = i{"type"}.getStr(i{"__typename"}.getStr) - if instrType == "TimelinePinEntry": + if i.getTypeName == "TimelinePinEntry": let tweets = extractTweetsFromEntry(i{"entry"}) if tweets.len > 0: var tweet = tweets[0] @@ -494,23 +549,20 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = result = @[] - let instructions = - if js{"data", "user"}.notNull: - ? js{"data", "user", "result", "timeline", "timeline", "instructions"} - else: - ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} - + let instructions = select( + js{"data", "user", "result", "timeline", "timeline", "instructions"}, + js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + ) if instructions.len == 0: return for i in instructions: - # TimelineAddToModule instruction is used by MediaTimelineV2 if i{"moduleItems"}.notNull: for item in i{"moduleItems"}: - with tweetResult, item{"item", "itemContent", "tweet_results", "result"}: - let t = parseGraphTweet(tweetResult, false) + with tweetResult, item.getTweetResult("item"): + let t = parseGraphTweet(tweetResult) if not t.available: - t.id = parseBiggestInt(item{"entryId"}.getStr.getId()) + t.id = item.getEntryId.getId let photo = extractGalleryPhoto(t) if photo.url.len > 0: @@ -520,12 +572,11 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = return continue - let instrType = i{"type"}.getStr(i{"__typename"}.getStr) - if instrType != "TimelineAddEntries": + if i.getTypeName != "TimelineAddEntries": continue for e in i{"entries"}: - let entryId = e{"entryId"}.getStr + let entryId = e.getEntryId if entryId.startsWith("tweet") or entryId.startsWith("profile-grid"): for t in extractTweetsFromEntry(e): let photo = extractGalleryPhoto(t) @@ -538,21 +589,24 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) - let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} + let instructions = select( + js{"data", "search", "timeline_response", "timeline", "instructions"}, + js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} + ) if instructions.len == 0: return for instruction in instructions: - let typ = instruction{"type"}.getStr + let typ = getTypeName(instruction) if typ == "TimelineAddEntries": for e in instruction{"entries"}: - let entryId = e{"entryId"}.getStr + let entryId = e.getEntryId when T is Tweets: if entryId.startsWith("tweet"): - with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: + with tweetRes, getTweetResult(e): let tweet = parseGraphTweet(tweetRes) if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + tweet.id = entryId.getId result.content.add tweet elif T is User: if entryId.startsWith("user"): diff --git a/src/parserutils.nim b/src/parserutils.nim index 7e246dd..f40082c 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -36,6 +36,12 @@ template `?`*(js: JsonNode): untyped = if j.isNull: return j +template select*(a, b: JsonNode): untyped = + if a.notNull: a else: b + +template select*(a, b, c: JsonNode): untyped = + if a.notNull: a elif b.notNull: b else: c + template with*(ident, value, body): untyped = if true: let ident {.inject.} = value @@ -54,6 +60,20 @@ template getError*(js: JsonNode): Error = if js.kind != JArray or js.len == 0: null else: Error(js[0]{"code"}.getInt) +proc getTweetResult*(js: JsonNode; root="content"): JsonNode = + select( + js{root, "content", "tweet_results", "result"}, + js{root, "itemContent", "tweet_results", "result"}, + js{root, "content", "tweetResult", "result"} + ) + +template getTypeName*(js: JsonNode): string = + js{"__typename"}.getStr(js{"type"}.getStr) + +template getEntryId*(e: JsonNode): string = + e{"entryId"}.getStr(e{"entry_id"}.getStr) + + template parseTime(time: string; f: static string; flen: int): DateTime = if time.len != flen: return parse(time, f, utc()) @@ -64,29 +84,24 @@ proc getDateTime*(js: JsonNode): DateTime = proc getTime*(js: JsonNode): DateTime = parseTime(js.getStr, "ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30) -proc getId*(id: string): string {.inline.} = +proc getTimeFromMs*(js: JsonNode): DateTime = + let ms = js.getInt(0) + if ms == 0: return + let seconds = ms div 1000 + return fromUnix(seconds).utc() + +proc getId*(id: string): int64 {.inline.} = let start = id.rfind("-") - if start < 0: return id - id[start + 1 ..< id.len] + if start < 0: + return parseBiggestInt(id) + return parseBiggestInt(id[start + 1 ..< id.len]) proc getId*(js: JsonNode): int64 {.inline.} = case js.kind - of JString: return parseBiggestInt(js.getStr("0")) + of JString: return js.getStr("0").getId of JInt: return js.getBiggestInt() else: return 0 -proc getEntryId*(js: JsonNode): string {.inline.} = - let entry = js{"entryId"}.getStr - if entry.len == 0: return - - if "tweet" in entry or "sq-I-t" in entry: - return entry.getId - elif "tombstone" in entry: - return js{"content", "item", "content", "tombstone", "tweet", "id"}.getStr - else: - echo "unknown entry: ", entry - return - template getStrVal*(js: JsonNode; default=""): string = js{"string_value"}.getStr(default) @@ -157,12 +172,6 @@ proc getMp4Resolution*(url: string): int = # cannot determine resolution (e.g. m3u8/non-mp4 video) return 0 -proc getVideoViewCount*(js: JsonNode): string = - with stats, js{"ext_media_stats"}: - return stats{"view_count"}.getStr($stats{"viewCount"}.getInt) - - return $js{"mediaStats", "viewCount"}.getInt(0) - proc extractSlice(js: JsonNode): Slice[int] = result = js["indices"][0].getInt ..< js["indices"][1].getInt diff --git a/src/types.nim b/src/types.nim index 5a08bb7..f16fe6f 100644 --- a/src/types.nim +++ b/src/types.nim @@ -121,7 +121,6 @@ type durationMs*: int url*: string thumb*: string - views*: string available*: bool reason*: string title*: string @@ -202,7 +201,6 @@ type replies*: int retweets*: int likes*: int - quotes*: int views*: int Tweet* = ref object diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 6d76755..8ff8cb1 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -178,16 +178,12 @@ func formatStat(stat: int): string = if stat > 0: insertSep($stat, ',') else: "" -proc renderStats(stats: TweetStats; views: string): VNode = +proc renderStats(stats: TweetStats): VNode = buildHtml(tdiv(class="tweet-stats")): span(class="tweet-stat"): icon "comment", formatStat(stats.replies) span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) - span(class="tweet-stat"): icon "quote", formatStat(stats.quotes) span(class="tweet-stat"): icon "heart", formatStat(stats.likes) - if stats.views > 0: - span(class="tweet-stat"): icon "views", formatStat(stats.views) - if views.len > 0: - span(class="tweet-stat"): icon "play", insertSep(views, ',') + span(class="tweet-stat"): icon "views", formatStat(stats.views) proc renderReply(tweet: Tweet): VNode = buildHtml(tdiv(class="replying-to")): @@ -303,7 +299,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; a(class="tweet-link", href=getLink(tweet)) tdiv(class="tweet-body"): - var views = "" renderHeader(tweet, retweet, pinned, prefs) if not afterTweet and index == 0 and tweet.reply.len > 0 and @@ -327,10 +322,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; renderAlbum(tweet) elif tweet.video.isSome: renderVideo(tweet.video.get(), prefs, path) - views = tweet.video.get().views elif tweet.gif.isSome: renderGif(tweet.gif.get(), prefs) - views = "GIF" if tweet.poll.isSome: renderPoll(tweet.poll.get()) @@ -345,7 +338,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; renderMediaTags(tweet.mediaTags) if not prefs.hideTweetStats: - renderStats(tweet.stats, views) + renderStats(tweet.stats) if showThread: a(class="show-thread", href=("/i/status/" & $tweet.threadId)): From f8a17fdaa5d88ae425be3cf24a56fcc364d35a11 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 23 Nov 2025 17:28:11 +0100 Subject: [PATCH 148/309] Remove Nim 1.6.x support Fixes #1311 --- .github/workflows/run-tests.yml | 2 +- nitter.nimble | 2 +- src/parserutils.nim | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 46e4ace..f4639a4 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -23,7 +23,7 @@ jobs: runs-on: buildjet-2vcpu-ubuntu-2204 strategy: matrix: - nim: ["1.6.x", "2.0.x", "2.2.x", "devel"] + nim: ["2.0.x", "2.2.x", "devel"] steps: - name: Checkout Code uses: actions/checkout@v4 diff --git a/nitter.nimble b/nitter.nimble index 37f9229..7ff8196 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -10,7 +10,7 @@ bin = @["nitter"] # Dependencies -requires "nim >= 1.6.10" +requires "nim >= 2.0.0" requires "jester#baca3f" requires "karax#5cf360c" requires "sass#7dfdd03" diff --git a/src/parserutils.nim b/src/parserutils.nim index f40082c..72c50e1 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -50,8 +50,7 @@ template with*(ident, value, body): untyped = template with*(ident; value: JsonNode; body): untyped = if true: let ident {.inject.} = value - # value.notNull causes a compilation error for versions < 1.6.14 - if notNull(value): body + if value.notNull: body template getCursor*(js: JsonNode): string = js{"content", "operation", "cursor", "value"}.getStr From 5b4a3fe691ce7a1a8e7cd491b0b37d3c95a621ce Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 23 Nov 2025 19:26:48 +0100 Subject: [PATCH 149/309] Redirect /i/status/id/history to /i/status/id Fixes #1231 --- src/routes/status.nim | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/routes/status.nim b/src/routes/status.nim index 7e89220..0168dac 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -31,8 +31,6 @@ proc createStatusRouter*(cfg: Config) = resp $renderReplies(replies, prefs, getPath()) let conv = await getTweet(id, getCursor()) - if conv == nil: - echo "nil conv" if conv == nil or conv.tweet == nil or conv.tweet.id == 0: var error = "Tweet not found" @@ -68,7 +66,7 @@ proc createStatusRouter*(cfg: Config) = get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] - cond @"m" in ["video", "photo"] + cond @"m" in ["video", "photo", "history"] redirect("/$1/status/$2" % [@"name", @"id"]) get "/@name/statuses/@id/?": @@ -76,6 +74,6 @@ proc createStatusRouter*(cfg: Config) = get "/i/web/status/@id": redirect("/i/status/" & @"id") - + get "/@name/thread/@id/?": redirect("/$1/status/$2" % [@"name", @"id"]) From 53edbbc4e9d5cc8177596472894d49ea753a7049 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 23 Nov 2025 19:58:24 +0100 Subject: [PATCH 150/309] Fix broken tweet pagination ("Load more" button) Fixes #1277 --- src/parser.nim | 1 + 1 file changed, 1 insertion(+) diff --git a/src/parser.nim b/src/parser.nim index 700e896..5bf2b0b 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -485,6 +485,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.before.content.add tweet elif entryId.startsWith("cursor-bottom"): var cursorValue = select( + e{"content", "value"}, e{"content", "content", "value"}, e{"content", "itemContent", "value"} ) From 25df68209472133bf0b9f9abecc725587d9facba Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 151/309] Expose username as HTML attribute Fixes #551 --- src/views/timeline.nim | 2 +- src/views/tweet.nim | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index abeb6d3..a205c04 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -56,7 +56,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = index=i, last=(i == thread.high), showThread=show) proc renderUser(user: User; prefs: Prefs): VNode = - buildHtml(tdiv(class="timeline-item")): + buildHtml(tdiv(class="timeline-item", data-username=user.username)): a(class="tweet-link", href=("/" & user.username)) tdiv(class="tweet-body profile-result"): tdiv(class="tweet-header"): diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 8ff8cb1..552ab89 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -272,7 +272,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; divClass = "thread-last " & class if not tweet.available: - return buildHtml(tdiv(class=divClass & "unavailable timeline-item")): + return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)): tdiv(class="unavailable-box"): if tweet.tombstone.len > 0: text tweet.tombstone @@ -294,7 +294,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; tweet = tweet.retweet.get retweet = fullTweet.user.fullname - buildHtml(tdiv(class=("timeline-item " & divClass))): + buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)): if not mainTweet: a(class="tweet-link", href=getLink(tweet)) From 1657eeb769d79ffe414ec31bd4ce4686e3a3400c Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 152/309] Fix canonical link causing redirects to Twitter Fixes #526 --- src/formatters.nim | 2 +- src/views/general.nim | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index cafaa4f..7bbbe8b 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -6,7 +6,7 @@ import types, utils, query const cards = "cards.twitter.com/cards" tco = "https://t.co" - twitter = parseUri("https://twitter.com") + twitter = parseUri("https://x.com") let twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com" diff --git a/src/views/general.nim b/src/views/general.nim index 0091c74..faf4c1d 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -31,14 +31,14 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = icon "search", title="Search", href="/search" if cfg.enableRss and rss.len > 0: icon "rss", title="RSS Feed", href=rss - icon "bird", title="Open in Twitter", href=canonical + icon "bird", title="Open in X", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp icon "info", title="About", href="/about" icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; - rss=""; canonical=""): VNode = + rss=""; alternate=""): VNode = var theme = prefs.theme.toTheme if "theme" in req.params: theme = req.params["theme"].toTheme @@ -66,8 +66,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, href=opensearchUrl) - if canonical.len > 0: - link(rel="canonical", href=canonical) + if alternate.len > 0: + link(rel="alternate", href=alternate, title="View on X") if cfg.enableRss and rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") @@ -125,14 +125,14 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; images: seq[string] = @[]; banner=""): string = - let canonical = getTwitterLink(req.path, req.params) + let twitterLink = getTwitterLink(req.path, req.params) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, canonical) + rss, twitterLink) body: - renderNavbar(cfg, req, rss, canonical) + renderNavbar(cfg, req, rss, twitterLink) tdiv(class="container"): body From d47eb8f0eb6ba34055ba80e1537e7b415107a7af Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 153/309] Fix double slashes in url replacements Fixes #520 --- src/formatters.nim | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 7bbbe8b..e491928 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -59,25 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string = result = body if prefs.replaceYouTube.len > 0 and "youtu" in result: - result = result.replace(ytRegex, prefs.replaceYouTube) + let youtubeHost = strip(prefs.replaceYouTube, chars={'/'}) + result = result.replace(ytRegex, youtubeHost) if prefs.replaceTwitter.len > 0: + let twitterHost = strip(prefs.replaceTwitter, chars={'/'}) if tco in result: - result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") + result = result.replace(tco, https & twitterHost & "/t.co") if "x.com" in result: - result = result.replace(xRegex, prefs.replaceTwitter) + result = result.replace(xRegex, twitterHost) result = result.replacef(xLinkRegex, a( - prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) + twitterHost & "$2", href = https & twitterHost & "$1")) if "twitter.com" in result: - result = result.replace(cards, prefs.replaceTwitter & "/cards") - result = result.replace(twRegex, prefs.replaceTwitter) + result = result.replace(cards, twitterHost & "/cards") + result = result.replace(twRegex, twitterHost) result = result.replacef(twLinkRegex, a( - prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) + twitterHost & "$2", href = https & twitterHost & "$1")) if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): - result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") - result = result.replace(rdRegex, prefs.replaceReddit) - if prefs.replaceReddit in result and "/gallery/" in result: + let redditHost = strip(prefs.replaceReddit, chars={'/'}) + result = result.replace(rdShortRegex, redditHost & "/comments/") + result = result.replace(rdRegex, redditHost) + if redditHost in result and "/gallery/" in result: result = result.replace("/gallery/", "/comments/") if absolute.len > 0 and "href" in result: From 4748311f8dd9de0e3cab7cd00027c333b024dd9b Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 154/309] Fix intent/follow URL redirect Fixes #629 --- src/routes/timeline.nim | 6 ++++++ src/routes/unsupported.nim | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 7a10e91..49c7ce2 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -105,6 +105,12 @@ proc createTimelineRouter*(cfg: Config) = get "/intent/user": respUserId() + get "/intent/follow/?": + let username = request.params.getOrDefault("screen_name") + if username.len == 0: + resp Http400, showError("Missing screen_name parameter", cfg) + redirect("/" & username) + get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 0c085d4..362b36b 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -17,7 +17,7 @@ proc createUnsupportedRouter*(cfg: Config) = get "/@name/lists/?": feature() get "/intent/?@i?": - cond @"i" notin ["user"] + cond @"i" notin ["user", "follow"] feature() get "/i/@i?/?@j?": From f038b53fa2790866742dc1d6ca511950ed8bf276 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 155/309] Fix body font size to match x.com Fixes #711 --- src/sass/index.scss | 2 +- src/views/general.nim | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sass/index.scss b/src/sass/index.scss index 6cab48e..36a8a93 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -51,7 +51,7 @@ body { background-color: var(--bg_color); color: var(--fg_color); font-family: $font_0, $font_1, $font_2, $font_3; - font-size: 14px; + font-size: 15px; line-height: 1.3; margin: 0; } diff --git a/src/views/general.nim b/src/views/general.nim index faf4c1d..d431e98 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,7 +52,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=20") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") if theme.len > 0: From 4979d07f2ed9ee15238fd29d52acca42710a5333 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 156/309] Add spaces filter, remove broken filters --- src/query.nim | 5 ++--- src/views/search.nim | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/query.nim b/src/query.nim index 06e1da2..b6ff5df 100644 --- a/src/query.nim +++ b/src/query.nim @@ -6,10 +6,9 @@ import types const validFilters* = @[ "media", "images", "twimg", "videos", - "native_video", "consumer_video", "pro_video", + "native_video", "consumer_video", "spaces", "links", "news", "quote", "mentions", - "replies", "retweets", "nativeretweets", - "verified", "safe" + "replies", "retweets", "nativeretweets" ] emptyQuery* = "include:nativeretweets" diff --git a/src/views/search.nim b/src/views/search.nim index 9f7fc95..35af526 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -10,14 +10,12 @@ const toggles = { "media": "Media", "videos": "Videos", "news": "News", - "verified": "Verified", "native_video": "Native videos", "replies": "Replies", "links": "Links", "images": "Images", - "safe": "Safe", "quote": "Quotes", - "pro_video": "Pro videos" + "spaces": "Spaces" }.toOrderedTable proc renderSearch*(): VNode = From 12bbddf204e805885e0760b93120f90560b8354b Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 157/309] Update search panel grid layout and animation --- src/sass/include/_mixins.css | 13 +------------ src/sass/search.scss | 15 +++++++-------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css index 94e11ee..5fde51a 100644 --- a/src/sass/include/_mixins.css +++ b/src/sass/include/_mixins.css @@ -66,18 +66,7 @@ } #search-panel-toggle:checked ~ .search-panel { - @if $rows == 6 { - max-height: 200px !important; - } - @if $rows == 5 { - max-height: 300px !important; - } - @if $rows == 4 { - max-height: 300px !important; - } - @if $rows == 3 { - max-height: 365px !important; - } + max-height: 380px !important; } } } diff --git a/src/sass/search.scss b/src/sass/search.scss index f70f7ea..db0bc66 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -42,7 +42,7 @@ @include input-colors; } - @include create-toggle(search-panel, 200px); + @include create-toggle(search-panel, 380px); } .search-panel { @@ -104,19 +104,18 @@ .search-toggles { flex-grow: 1; display: grid; - grid-template-columns: repeat(6, auto); + grid-template-columns: repeat(5, auto); grid-column-gap: 10px; } .profile-tabs { @include search-resize(820px, 5); - @include search-resize(725px, 4); - @include search-resize(600px, 6); - @include search-resize(560px, 5); - @include search-resize(480px, 4); + @include search-resize(715px, 4); + @include search-resize(700px, 5); + @include search-resize(485px, 4); @include search-resize(410px, 3); } -@include search-resize(560px, 5); -@include search-resize(480px, 4); +@include search-resize(700px, 5); +@include search-resize(485px, 4); @include search-resize(410px, 3); From 78101df2cc22e30158bd77d18cc8267ca42834f2 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Mon, 24 Nov 2025 23:04:25 +0100 Subject: [PATCH 158/309] Style number input field --- src/query.nim | 15 ++++++++++----- src/sass/inputs.scss | 22 ++++++++++++++++++++-- src/sass/search.scss | 3 ++- src/types.nim | 2 +- src/views/general.nim | 2 +- src/views/renderutils.nim | 7 +++++++ src/views/search.nim | 6 +++--- 7 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/query.nim b/src/query.nim index b6ff5df..c77bf5f 100644 --- a/src/query.nim +++ b/src/query.nim @@ -17,6 +17,11 @@ template `@`(param: string): untyped = if param in pms: pms[param] else: "" +proc validateNumber(value: string): string = + if value.anyIt(not it.isDigit): + return "" + return value + proc initQuery*(pms: Table[string, string]; name=""): Query = result = Query( kind: parseEnum[QueryKind](@"f", tweets), @@ -25,7 +30,7 @@ proc initQuery*(pms: Table[string, string]; name=""): Query = excludes: validFilters.filterIt("e-" & it in pms), since: @"since", until: @"until", - near: @"near" + minLikes: validateNumber(@"min_faves") ) if name.len > 0: @@ -77,8 +82,8 @@ proc genQueryParam*(query: Query): string = result &= " since:" & query.since if query.until.len > 0: result &= " until:" & query.until - if query.near.len > 0: - result &= &" near:\"{query.near}\" within:15mi" + if query.minLikes.len > 0: + result &= " min_faves:" & query.minLikes if query.text.len > 0: if result.len > 0: result &= " " & query.text @@ -102,8 +107,8 @@ proc genQueryUrl*(query: Query): string = params.add "since=" & query.since if query.until.len > 0: params.add "until=" & query.until - if query.near.len > 0: - params.add "near=" & query.near + if query.minLikes.len > 0: + params.add "min_faves=" & query.minLikes if params.len > 0: result &= params.join("&") diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index 17c2a22..d6cbb1d 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -14,6 +14,7 @@ button { input[type="text"], input[type="date"], +input[type="number"], select { @include input-colors; background-color: var(--bg_elements); @@ -24,7 +25,12 @@ select { font-size: 14px; } -input[type="text"] { +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="text"], +input[type="number"] { height: 16px; } @@ -38,6 +44,17 @@ input[type="date"]::-webkit-inner-spin-button { display: none; } +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + display: none; + -webkit-appearance: none; + margin: 0; +} + input[type="date"]::-webkit-clear-button { margin-left: 17px; filter: grayscale(100%); @@ -164,7 +181,8 @@ input::-webkit-datetime-edit-year-field:focus { appearance: none; } - input[type="text"] { + input[type="text"], + input[type="number"] { position: absolute; right: 0; max-width: 140px; diff --git a/src/sass/search.scss b/src/sass/search.scss index db0bc66..234d677 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -24,7 +24,8 @@ height: 23px; } - input[type="text"] { + input[type="text"], + input[type="number"] { height: calc(100% - 4px); width: calc(100% - 8px); } diff --git a/src/types.nim b/src/types.nim index f16fe6f..20a49c9 100644 --- a/src/types.nim +++ b/src/types.nim @@ -140,7 +140,7 @@ type fromUser*: seq[string] since*: string until*: string - near*: string + minLikes*: string sep*: string Gif* = object diff --git a/src/views/general.nim b/src/views/general.nim index d431e98..23681b5 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,7 +52,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=20") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=21") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") if theme.len > 0: diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 41ef8df..fcdf06f 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -89,6 +89,13 @@ proc genDate*(pref, state: string): VNode = input(name=pref, `type`="date", value=state) icon "calendar" +proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode = + let p = placeholder + buildHtml(tdiv(class=("pref-group pref-input " & class))): + if label.len > 0: + label(`for`=pref): text label + input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1") + proc genImg*(url: string; class=""): VNode = buildHtml(): img(src=getPicUrl(url), class=class, alt="", loading="lazy") diff --git a/src/views/search.nim b/src/views/search.nim index 35af526..a43008f 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -51,7 +51,7 @@ proc renderSearchTabs*(query: Query): VNode = proc isPanelOpen(q: Query): bool = q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or - @[q.near, q.until, q.since].anyIt(it.len > 0)) + @[q.minLikes, q.until, q.since].anyIt(it.len > 0)) proc renderSearchPanel*(query: Query): VNode = let user = query.fromUser.join(",") @@ -83,8 +83,8 @@ proc renderSearchPanel*(query: Query): VNode = span(class="search-title"): text "-" genDate("until", query.until) tdiv: - span(class="search-title"): text "Near" - genInput("near", "", query.near, "Location...", autofocus=false) + span(class="search-title"): text "Minimum likes" + genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false) proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = From 2b922c049af946253df17a40f78d7e6027fdea0d Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Tue, 25 Nov 2025 01:02:45 +0100 Subject: [PATCH 159/309] Embed quote tweet in RSS (#1316) Fixes #132 Closes #820 --- src/views/rss.nimf | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 819f99c..a6f069c 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -25,7 +25,7 @@ #end proc # #proc getDescription(desc: string; cfg: Config): string = -Twitter feed for: ${desc}. Generated by ${cfg.hostname} +Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} #end proc # #proc getTweetsWithPinned(profile: Profile): seq[Tweets] = @@ -51,10 +51,6 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #let urlPrefix = getUrlPrefix(cfg) #let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) <p>${text.replace("\n", "<br>\n")}</p> -#if tweet.quote.isSome and get(tweet.quote).available: -# let quoteLink = getLink(get(tweet.quote)) -<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p> -#end if #if tweet.photos.len > 0: # for photo in tweet.photos: <img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" /> @@ -72,6 +68,20 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} <img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" /> # end if #end if +#if tweet.quote.isSome and get(tweet.quote).available: +# let quoteTweet = get(tweet.quote) +# let quoteLink = urlPrefix & getLink(quoteTweet) +<hr/> +<blockquote> +<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b> +<p> +${renderRssTweet(quoteTweet, cfg)} +</p> +<footer> +— <cite><a href="${quoteLink}">${quoteLink}</a> +</footer> +</blockquote> +#end if #end proc # #proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = From 404b06b5f35ae66f7f25bcc62edd5611ff00d77a Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Tue, 25 Nov 2025 01:03:45 +0100 Subject: [PATCH 160/309] Include "Video" and link for video tweets in RSS (#1315) Fixes #836 --- src/views/rss.nimf | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index a6f069c..23744e5 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -56,7 +56,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} <img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" /> # end for #elif tweet.video.isSome: -<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" /> +<a href="${urlPrefix}${tweet.getLink}"> +<br>Video<br> + <img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" /> +</a> #elif tweet.gif.isSome: # let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" # let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" From b83227aaf5acbd8e8803c85dfd2b8f311a021604 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Tue, 25 Nov 2025 17:23:04 +0100 Subject: [PATCH 161/309] Implement temp fix for cookie sessions Fixes #1319 --- src/api.nim | 47 ++++++++++++++++------------ src/apiutils.nim | 2 +- src/consts.nim | 24 ++++++++------ src/experimental/parser/graphql.nim | 24 +++++++++++--- src/experimental/parser/user.nim | 4 ++- src/experimental/types/graphuser.nim | 13 ++++++-- src/parser.nim | 2 +- 7 files changed, 77 insertions(+), 39 deletions(-) diff --git a/src/api.nim b/src/api.nim index ef3a0f9..fb9c516 100644 --- a/src/api.nim +++ b/src/api.nim @@ -13,47 +13,54 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = proc mediaUrl(id: string; cursor: string): SessionAwareUrl = let - cookieVariables = userMediaVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] + cookieVars = userMediaVars % [id, cursor] + oauthVars = restIdVars % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphUserMedia ? genParams(cookieVariables), - oauthUrl: graphUserMediaV2 ? genParams(oauthVariables) + cookieUrl: graphUserMedia ? genParams(cookieVars), + oauthUrl: graphUserMediaV2 ? genParams(oauthVars) ) proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = let - cookieVariables = userTweetsVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] + cookieVars = userTweetsVars % [id, cursor] + oauthVars = restIdVars % [id, cursor] result = SessionAwareUrl( - # cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles), - oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables) + # cookieUrl: graphUserTweets ? genParams(cookieVars, userTweetsFieldToggles), + oauthUrl: graphUserTweetsV2 ? genParams(oauthVars) ) # might change this in the future pending testing result.cookieUrl = result.oauthUrl proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = let - cookieVariables = userTweetsAndRepliesVariables % [id, cursor] - oauthVariables = restIdVariables % [id, cursor] + cookieVars = userTweetsAndRepliesVars % [id, cursor] + oauthVars = restIdVars % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles), - oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables) + cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVars, userTweetsFieldToggles), + oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVars) ) proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = let - cookieVariables = tweetDetailVariables % [id, cursor] - oauthVariables = tweetVariables % [id, cursor] + cookieVars = tweetDetailVars % [id, cursor] + oauthVars = tweetVars % [id, cursor] result = SessionAwareUrl( - cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles), - oauthUrl: graphTweet ? genParams(oauthVariables) + cookieUrl: graphTweetDetail ? genParams(cookieVars, tweetDetailFieldToggles), + oauthUrl: graphTweet ? genParams(oauthVars) + ) + +proc userUrl(username: string): SessionAwareUrl = + let + cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username + oauthVars = """{"screen_name": "$1"}""" % username + result = SessionAwareUrl( + cookieUrl: graphUser ? genParams(cookieVars, tweetDetailFieldToggles), + oauthUrl: graphUserV2 ? genParams(oauthVars) ) proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return - let - url = graphUser ? genParams("""{"screen_name": "$1"}""" % username) - js = await fetchRaw(url, Api.userScreenName) + let js = await fetchRaw(userUrl(username), Api.userScreenName) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = @@ -80,7 +87,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - url = graphListTweets ? genParams(restIdVariables % [id, cursor]) + url = graphListTweets ? genParams(restIdVars % [id, cursor]) result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = diff --git a/src/apiutils.nim b/src/apiutils.nim index defffd1..94d4e8a 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -50,7 +50,7 @@ proc genHeaders*(session: Session, url: string): HttpHeaders = of SessionKind.oauth: result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: - result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" + result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) diff --git a/src/consts.nim b/src/consts.nim index 792a519..e55ba0e 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -7,7 +7,8 @@ const gql = parseUri("https://api.x.com") / "graphql" - graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" + graphUser* = gql / "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" + graphUserV2* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" @@ -97,10 +98,14 @@ const "grok_translations_community_note_auto_translation_is_enabled": false, "grok_translations_post_auto_translation_is_enabled": false, "grok_translations_community_note_translation_is_enabled": false, - "grok_translations_timeline_user_bio_auto_translation_is_enabled": false + "grok_translations_timeline_user_bio_auto_translation_is_enabled": false, + "subscriptions_feature_can_gift_premium": false, + "responsive_web_twitter_article_notes_tab_enabled": false, + "subscriptions_verification_info_is_identity_verified_enabled": false, + "hidden_profile_subscriptions_enabled": false }""".replace(" ", "").replace("\n", "") - tweetVariables* = """{ + tweetVars* = """{ "postId": "$1", $2 "includeHasBirdwatchNotes": false, @@ -110,7 +115,7 @@ const "withV2Timeline": true }""".replace(" ", "").replace("\n", "") - tweetDetailVariables* = """{ + tweetDetailVars* = """{ "focalTweetId": "$1", $2 "referrer": "profile", @@ -123,12 +128,12 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - restIdVariables* = """{ + restIdVars* = """{ "rest_id": "$1", $2 "count": 20 }""" - userMediaVariables* = """{ + userMediaVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -137,7 +142,7 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsVariables* = """{ + userTweetsVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -145,7 +150,7 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsAndRepliesVariables* = """{ + userTweetsAndRepliesVars* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -153,5 +158,6 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - fieldToggles* = """{"withArticlePlainText":false}""" + userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}""" + userTweetsFieldToggles* = """{"withArticlePlainText":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 045a5d6..65ebc3d 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -1,6 +1,6 @@ import options, strutils import jsony -import user, ../types/[graphuser, graphlistmembers] +import user, utils, ../types/[graphuser, graphlistmembers] from ../../types import User, VerifiedType, Result, Query, QueryKind proc parseUserResult*(userResult: UserResult): User = @@ -15,22 +15,36 @@ proc parseUserResult*(userResult: UserResult): User = result.fullname = userResult.core.name result.userPic = userResult.avatar.imageUrl.replace("_normal", "") + if userResult.privacy.isSome: + result.protected = userResult.privacy.get.protected + + if userResult.location.isSome: + result.location = userResult.location.get.location + + if userResult.core.createdAt.len > 0: + result.joinDate = parseTwitterDate(userResult.core.createdAt) + if userResult.verification.isSome: let v = userResult.verification.get if v.verifiedType != VerifiedType.none: result.verifiedType = v.verifiedType - if userResult.profileBio.isSome: + if userResult.profileBio.isSome and result.bio.len == 0: result.bio = userResult.profileBio.get.description proc parseGraphUser*(json: string): User = if json.len == 0 or json[0] != '{': return - let raw = json.fromJson(GraphUser) - let userResult = raw.data.userResult.result + let + raw = json.fromJson(GraphUser) + userResult = + if raw.data.userResult.isSome: raw.data.userResult.get.result + elif raw.data.user.isSome: raw.data.user.get.result + else: UserResult() - if userResult.unavailableReason.get("") == "Suspended": + if userResult.unavailableReason.get("") == "Suspended" or + userResult.reason.get("") == "Suspended": return User(suspended: true) result = parseUserResult(userResult) diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 498757a..8517bdc 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -58,11 +58,13 @@ proc toUser*(raw: RawUser): User = media: raw.mediaCount, verifiedType: raw.verifiedType, protected: raw.protected, - joinDate: parseTwitterDate(raw.createdAt), banner: getBanner(raw), userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") ) + if raw.createdAt.len > 0: + result.joinDate = parseTwitterDate(raw.createdAt) + if raw.pinnedTweetIdsStr.len > 0: result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index d732b4e..62c6612 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -3,7 +3,7 @@ from ../../types import User, VerifiedType type GraphUser* = object - data*: tuple[userResult: UserData] + data*: tuple[userResult: Option[UserData], user: Option[UserData]] UserData* = object result*: UserResult @@ -22,15 +22,24 @@ type Verification* = object verifiedType*: VerifiedType + Location* = object + location*: string + + Privacy* = object + protected*: bool + UserResult* = object legacy*: User restId*: string isBlueVerified*: bool - unavailableReason*: Option[string] core*: UserCore avatar*: UserAvatar + unavailableReason*: Option[string] + reason*: Option[string] + privacy*: Option[Privacy] profileBio*: Option[UserBio] verification*: Option[Verification] + location*: Option[Location] proc enumHook*(s: string; v: var VerifiedType) = v = try: diff --git a/src/parser.nim b/src/parser.nim index 5bf2b0b..7b1e1c0 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -21,7 +21,7 @@ proc parseUser(js: JsonNode; id=""): User = tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, - protected: js{"protected"}.getBool, + protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool), joinDate: js{"created_at"}.getTime ) From 8516ebe2b7ac772f4f33ee141f2e03fdf4d21467 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 29 Nov 2025 00:36:44 +0100 Subject: [PATCH 162/309] Fix 'key not found in object: expanded_url' error Fixes #1318 --- src/parser.nim | 4 ++-- src/parserutils.nim | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 7b1e1c0..9b4dc2f 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -184,7 +184,7 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) = # Remove media URLs from text with mediaList, js{"legacy", "entities", "media"}: for url in mediaList: - let expandedUrl = url{"expanded_url"}.getStr + let expandedUrl = url.getExpandedUrl if result.text.endsWith(expandedUrl): result.text.removeSuffix(expandedUrl) result.text = result.text.strip() @@ -267,7 +267,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = for u in ? urls: if u{"url"}.getStr == result.url: - result.url = u{"expanded_url"}.getStr + result.url = u.getExpandedUrl(result.url) break if kind in {videoDirectMessage, imageDirectMessage}: diff --git a/src/parserutils.nim b/src/parserutils.nim index 72c50e1..b6ccd52 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -112,6 +112,9 @@ proc getImageStr*(js: JsonNode): string = template getImageVal*(js: JsonNode): string = js{"image_value", "url"}.getImageStr +template getExpandedUrl*(js: JsonNode; fallback=""): string = + js{"expanded_url"}.getStr(js{"url"}.getStr(fallback)) + proc getCardUrl*(js: JsonNode; kind: CardKind): string = result = js{"website_url"}.getStrVal if kind == promoVideoConvo: @@ -177,7 +180,7 @@ proc extractSlice(js: JsonNode): Slice[int] = proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode; textLen: int; hideTwitter = false) = let - url = js["expanded_url"].getStr + url = js.getExpandedUrl slice = js.extractSlice if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: @@ -238,7 +241,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) = ent = ? js{"entities"} with urls, ent{"url", "urls"}: - user.website = urls[0]{"expanded_url"}.getStr + user.website = urls[0].getExpandedUrl var replacements = newSeq[ReplaceSlice]() @@ -268,7 +271,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) if hasCard and u{"url"}.getStr == get(tweet.card).url: - get(tweet.card).url = u{"expanded_url"}.getStr + get(tweet.card).url = u.getExpandedUrl with media, entities{"media"}: for m in media: From dae68b4f13c773ca0394e30dc87d7b001a74431f Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 29 Nov 2025 01:05:57 +0100 Subject: [PATCH 163/309] Ignore null errors, they're internal API errors --- src/apiutils.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 94d4e8a..f48cda2 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -8,7 +8,7 @@ const rlRemaining = "x-rate-limit-remaining" rlReset = "x-rate-limit-reset" rlLimit = "x-rate-limit-limit" - errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} + errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} var pool: HttpPool From 31d210ca47f09a756d761699bd41a35cabb4c3ab Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 29 Nov 2025 01:13:08 +0100 Subject: [PATCH 164/309] Add experimental x-client-transaction-id support (#1324) * Add experimental x-client-transaction-id support * Remove broken test --- nitter.example.conf | 1 + src/api.nim | 131 ++++++++++++++++---------------- src/apiutils.nim | 74 ++++++++++-------- src/auth.nim | 32 +++++--- src/config.nim | 3 +- src/consts.nim | 40 +++++----- src/experimental/parser/tid.nim | 8 ++ src/experimental/types/tid.nim | 4 + src/nitter.nim | 3 +- src/tid.nim | 62 +++++++++++++++ src/types.nim | 29 +++---- tests/test_card.py | 7 +- tools/create_session_browser.py | 2 +- 13 files changed, 239 insertions(+), 157 deletions(-) create mode 100644 src/experimental/parser/tid.nim create mode 100644 src/experimental/types/tid.nim create mode 100644 src/tid.nim diff --git a/nitter.example.conf b/nitter.example.conf index bddb9a4..dfdaf50 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -26,6 +26,7 @@ enableRSS = true # set this to false to disable RSS feeds enableDebug = false # enable request logs and debug endpoints (/.sessions) proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" +disableTid = false # enable this if cookie-based auth is failing # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/src/api.nim b/src/api.nim index fb9c516..e97b4e0 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables +import asyncdispatch, httpclient, strutils, sequtils, sugar import packedjson import types, query, formatters, consts, apiutils, parser import experimental/parser as newParser @@ -11,95 +11,91 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = if fieldToggles.len > 0: result.add ("fieldToggles", fieldToggles) -proc mediaUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVars = userMediaVars % [id, cursor] - oauthVars = restIdVars % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphUserMedia ? genParams(cookieVars), - oauthUrl: graphUserMediaV2 ? genParams(oauthVars) +proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl = + return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles)) + +proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq = + let url = apiUrl(endpoint, variables, fieldToggles) + return ApiReq(cookie: url, oauth: url) + +proc mediaUrl(id: string; cursor: string): ApiReq = + result = ApiReq( + cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]), + oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor]) ) -proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVars = userTweetsVars % [id, cursor] - oauthVars = restIdVars % [id, cursor] - result = SessionAwareUrl( - # cookieUrl: graphUserTweets ? genParams(cookieVars, userTweetsFieldToggles), - oauthUrl: graphUserTweetsV2 ? genParams(oauthVars) +proc userTweetsUrl(id: string; cursor: string): ApiReq = + result = ApiReq( + # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), + oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor]) ) # might change this in the future pending testing - result.cookieUrl = result.oauthUrl + result.cookie = result.oauth -proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVars = userTweetsAndRepliesVars % [id, cursor] - oauthVars = restIdVars % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVars, userTweetsFieldToggles), - oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVars) +proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = + let cookieVars = userTweetsAndRepliesVars % [id, cursor] + result = ApiReq( + cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles), + oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor]) ) -proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = - let - cookieVars = tweetDetailVars % [id, cursor] - oauthVars = tweetVars % [id, cursor] - result = SessionAwareUrl( - cookieUrl: graphTweetDetail ? genParams(cookieVars, tweetDetailFieldToggles), - oauthUrl: graphTweet ? genParams(oauthVars) +proc tweetDetailUrl(id: string; cursor: string): ApiReq = + let cookieVars = tweetDetailVars % [id, cursor] + result = ApiReq( + cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), + oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) ) -proc userUrl(username: string): SessionAwareUrl = - let - cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username - oauthVars = """{"screen_name": "$1"}""" % username - result = SessionAwareUrl( - cookieUrl: graphUser ? genParams(cookieVars, tweetDetailFieldToggles), - oauthUrl: graphUserV2 ? genParams(oauthVars) +proc userUrl(username: string): ApiReq = + let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username + result = ApiReq( + cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles), + oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username) ) proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return - let js = await fetchRaw(userUrl(username), Api.userScreenName) + let js = await fetchRaw(userUrl(username)) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = if id.len == 0 or id.any(c => not c.isDigit): return let - url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id) - js = await fetchRaw(url, Api.userRestId) + url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id) + js = await fetchRaw(url) result = parseGraphUser(js) proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - js = case kind - of TimelineKind.tweets: - await fetch(userTweetsUrl(id, cursor), Api.userTweets) - of TimelineKind.replies: - await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) - of TimelineKind.media: - await fetch(mediaUrl(id, cursor), Api.userMedia) + url = case kind + of TimelineKind.tweets: userTweetsUrl(id, cursor) + of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) + of TimelineKind.media: mediaUrl(id, cursor) + js = await fetch(url) result = parseGraphTimeline(js, after) proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - url = graphListTweets ? genParams(restIdVars % [id, cursor]) - result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets + url = apiReq(graphListTweets, restIdVars % [id, cursor]) + js = await fetch(url) + result = parseGraphTimeline(js, after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list} - url = graphListBySlug ? genParams($variables) - result = parseGraphList(await fetch(url, Api.listBySlug)) + url = apiReq(graphListBySlug, $variables) + js = await fetch(url) + result = parseGraphList(js) proc getGraphList*(id: string): Future[List] {.async.} = - let - url = graphListById ? genParams("""{"listId": "$1"}""" % id) - result = parseGraphList(await fetch(url, Api.list)) + let + url = apiReq(graphListById, """{"listId": "$1"}""" % id) + js = await fetch(url) + result = parseGraphList(js) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = if list.id.len == 0: return @@ -113,22 +109,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} } if after.len > 0: variables["cursor"] = % after - let url = graphListMembers ? genParams($variables) - result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) + let + url = apiReq(graphListMembers, $variables) + js = await fetchRaw(url) + result = parseGraphListMembers(js, after) proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let - variables = """{"rest_id": "$1"}""" % id - params = {"variables": variables, "features": gqlFeatures} - js = await fetch(graphTweetResult ? params, Api.tweetResult) + url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id) + js = await fetch(url) result = parseGraphTweetResult(js) proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) + js = await fetch(tweetDetailUrl(id, cursor)) result = parseGraphConversation(js, id) proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = @@ -157,8 +154,10 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = } if after.len > 0: variables["cursor"] = % after - let url = graphSearchTimeline ? genParams($variables) - result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) + let + url = apiReq(graphSearchTimeline, $variables) + js = await fetch(url) + result = parseGraphSearch[Tweets](js, after) result.query = query proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = @@ -179,13 +178,15 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} variables["cursor"] = % after result.beginning = false - let url = graphSearchTimeline ? genParams($variables) - result = parseGraphSearch[User](await fetch(url, Api.search), after) + let + url = apiReq(graphSearchTimeline, $variables) + js = await fetch(url) + result = parseGraphSearch[User](js, after) result.query = query proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return - let js = await fetch(mediaUrl(id, ""), Api.userMedia) + let js = await fetch(mediaUrl(id, "")) result = parseGraphPhotoRail(js) proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = diff --git a/src/apiutils.nim b/src/apiutils.nim index f48cda2..b288141 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import jsony, packedjson, zippy, oauth1 -import types, auth, consts, parserutils, http_pool +import types, auth, consts, parserutils, http_pool, tid import experimental/types/common const @@ -10,7 +10,21 @@ const rlLimit = "x-rate-limit-limit" errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} -var pool: HttpPool +var + pool: HttpPool + disableTid: bool + +proc setDisableTid*(disable: bool) = + disableTid = disable + +proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = + case sessionKind + of oauth: + let o = req.oauth + parseUri("https://api.x.com/graphql") / o.endpoint ? o.params + of cookie: + let c = req.cookie + parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = let @@ -32,15 +46,15 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getCookieHeader(authToken, ct0: string): string = "auth_token=" & authToken & "; ct0=" & ct0 -proc genHeaders*(session: Session, url: string): HttpHeaders = +proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result = newHttpHeaders({ "connection": "keep-alive", "content-type": "application/json", "x-twitter-active-user": "yes", "x-twitter-client-language": "en", - "authority": "api.x.com", + "origin": "https://x.com", "accept-encoding": "gzip", - "accept-language": "en-US,en;q=0.9", + "accept-language": "en-US,en;q=0.5", "accept": "*/*", "DNT": "1", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" @@ -48,15 +62,20 @@ proc genHeaders*(session: Session, url: string): HttpHeaders = case session.kind of SessionKind.oauth: - result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) + result["authority"] = "api.x.com" + result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: - result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) + if disableTid: + result["authorization"] = bearerToken2 + else: + result["authorization"] = bearerToken + result["x-client-transaction-id"] = await genTid(url.path) -proc getAndValidateSession*(api: Api): Future[Session] {.async.} = - result = await getSession(api) +proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} = + result = await getSession(req) case result.kind of SessionKind.oauth: if result.oauthToken.len == 0: @@ -73,7 +92,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - pool.use(genHeaders(session, $url)): + pool.use(await genHeaders(session, url)): template getContent = resp = await c.get($url) result = await resp.body @@ -89,7 +108,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) limit = parseInt(resp.headers[rlLimit]) - session.setRateLimit(api, remaining, reset, limit) + session.setRateLimit(req, remaining, reset, limit) if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": @@ -98,24 +117,22 @@ template fetchImpl(result, fetchBody) {.dirty.} = if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) if errors notin errorsToSkip: - echo "Fetch error, API: ", api, ", errors: ", errors + echo "Fetch error, API: ", url.path, ", errors: ", errors if errors in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() elif errors in {rateLimited}: # rate limit hit, resets after 24 hours - setLimited(session, api) + setLimited(session, req) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): - echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty - session.apis[api].remaining = 0 - # rate limit hit, resets after the 15 minute window + echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty raise rateLimitError() fetchBody if resp.status == $Http400: - echo "ERROR 400, ", api, ": ", result + echo "ERROR 400, ", url.path, ": ", result raise newException(InternalError, $url) except InternalError as e: raise e @@ -134,19 +151,16 @@ template retry(bod) = try: bod except RateLimitError: - echo "[sessions] Rate limited, retrying ", api, " request..." + echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..." bod -proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = +proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = retry: var body: string - session = await getAndValidateSession(api) + session = await getAndValidateSession(req) - when url is SessionAwareUrl: - let url = case session.kind - of SessionKind.oauth: url.oauthUrl - of SessionKind.cookie: url.cookieUrl + let url = req.toUrl(session.kind) fetchImpl body: if body.startsWith('{') or body.startsWith('['): @@ -157,19 +171,15 @@ proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = let error = result.getError if error != null and error notin errorsToSkip: - echo "Fetch error, API: ", api, ", error: ", error + echo "Fetch error, API: ", url.path, ", error: ", error if error in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() -proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = +proc fetchRaw*(req: ApiReq): Future[string] {.async.} = retry: - var session = await getAndValidateSession(api) - - when url is SessionAwareUrl: - let url = case session.kind - of SessionKind.oauth: url.oauthUrl - of SessionKind.cookie: url.cookieUrl + var session = await getAndValidateSession(req) + let url = req.toUrl(session.kind) fetchImpl result: if not (result.startsWith('{') or result.startsWith('[')): diff --git a/src/auth.nim b/src/auth.nim index 734b43e..5d7ef0e 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -1,6 +1,6 @@ #SPDX-License-Identifier: AGPL-3.0-only -import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os] -import types +import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os] +import types, consts import experimental/parser/session # max requests at a time per session to avoid race conditions @@ -15,6 +15,11 @@ var template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") +proc endpoint(req: ApiReq; session: Session): string = + case session.kind + of oauth: req.oauth.endpoint + of cookie: req.cookie.endpoint + proc pretty*(session: Session): string = if session.isNil: return "<null>" @@ -122,11 +127,12 @@ proc rateLimitError*(): ref RateLimitError = proc noSessionsError*(): ref NoSessionsError = newException(NoSessionsError, "no sessions available") -proc isLimited(session: Session; api: Api): bool = +proc isLimited(session: Session; req: ApiReq): bool = if session.isNil: return true - if session.limited and api != Api.userTweets: + let api = req.endpoint(session) + if session.limited and api != graphUserTweetsV2: if (epochTime().int - session.limitedAt) > hourInSeconds: session.limited = false log "resetting limit: ", session.pretty @@ -140,8 +146,8 @@ proc isLimited(session: Session; api: Api): bool = else: return false -proc isReady(session: Session; api: Api): bool = - not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api)) +proc isReady(session: Session; req: ApiReq): bool = + not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req)) proc invalidate*(session: var Session) = if session.isNil: return @@ -156,24 +162,26 @@ proc release*(session: Session) = if session.isNil: return dec session.pending -proc getSession*(api: Api): Future[Session] {.async.} = +proc getSession*(req: ApiReq): Future[Session] {.async.} = for i in 0 ..< sessionPool.len: - if result.isReady(api): break + if result.isReady(req): break result = sessionPool.sample() - if not result.isNil and result.isReady(api): + if not result.isNil and result.isReady(req): inc result.pending else: - log "no sessions available for API: ", api + log "no sessions available for API: ", req.cookie.endpoint raise noSessionsError() -proc setLimited*(session: Session; api: Api) = +proc setLimited*(session: Session; req: ApiReq) = + let api = req.endpoint(session) session.limited = true session.limitedAt = epochTime().int log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty -proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = +proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) = # avoid undefined behavior in race conditions + let api = req.endpoint(session) if api in session.apis: let rateLimit = session.apis[api] if rateLimit.reset >= reset and rateLimit.remaining < remaining: diff --git a/src/config.nim b/src/config.nim index 1b05ffe..571508b 100644 --- a/src/config.nim +++ b/src/config.nim @@ -40,7 +40,8 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = enableRss: cfg.get("Config", "enableRSS", true), enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), - proxyAuth: cfg.get("Config", "proxyAuth", "") + proxyAuth: cfg.get("Config", "proxyAuth", ""), + disableTid: cfg.get("Config", "disableTid", false) ) return (conf, cfg) diff --git a/src/consts.nim b/src/consts.nim index e55ba0e..6456efc 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -1,29 +1,29 @@ # SPDX-License-Identifier: AGPL-3.0-only -import uri, strutils +import strutils const consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" + bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" + bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" - gql = parseUri("https://api.x.com") / "graphql" - - graphUser* = gql / "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" - graphUserV2* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" - graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" - graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" - graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" - graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" - graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" - graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" - graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" - graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" - graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" - graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" - graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" - graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" - graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" - graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" - graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" + graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" + graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" + graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" + graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" + graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets" + graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" + graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia" + graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" + graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" + graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" + graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" + graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" + graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" + graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" + graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" + graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" gqlFeatures* = """{ "android_ad_formats_media_component_render_overlay_enabled": false, diff --git a/src/experimental/parser/tid.nim b/src/experimental/parser/tid.nim new file mode 100644 index 0000000..28fccea --- /dev/null +++ b/src/experimental/parser/tid.nim @@ -0,0 +1,8 @@ +import jsony +import ../types/tid +export TidPair + +proc parseTidPairs*(raw: string): seq[TidPair] = + result = raw.fromJson(seq[TidPair]) + if result.len == 0: + raise newException(ValueError, "Parsing pairs failed: " & raw) diff --git a/src/experimental/types/tid.nim b/src/experimental/types/tid.nim new file mode 100644 index 0000000..ad036d9 --- /dev/null +++ b/src/experimental/types/tid.nim @@ -0,0 +1,4 @@ +type + TidPair* = object + animationKey*: string + verification*: string diff --git a/src/nitter.nim b/src/nitter.nim index f81dc1c..e6d66ab 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -6,7 +6,7 @@ from os import getEnv import jester -import types, config, prefs, formatters, redis_cache, http_pool, auth +import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, debug, @@ -37,6 +37,7 @@ setHmacKey(cfg.hmacKey) setProxyEncoding(cfg.base64Media) setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) +setDisableTid(cfg.disableTid) initAboutPage(cfg.staticDir) waitFor initRedisPool(cfg) diff --git a/src/tid.nim b/src/tid.nim new file mode 100644 index 0000000..7b453fb --- /dev/null +++ b/src/tid.nim @@ -0,0 +1,62 @@ +import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times] +import nimcrypto +import experimental/parser/tid + +randomize() + +const defaultKeyword = "obfiowerehiring"; +const pairsUrl = + "https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json"; + +var + cachedPairs: seq[TidPair] = @[] + lastCached = 0 + # refresh every hour + ttlSec = 60 * 60 + +proc getPair(): Future[TidPair] {.async.} = + if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec: + lastCached = int(epochTime()) + + let client = newAsyncHttpClient() + defer: client.close() + + let resp = await client.get(pairsUrl) + if resp.status == $Http200: + cachedPairs = parseTidPairs(await resp.body) + + return sample(cachedPairs) + +proc encodeSha256(text: string): array[32, byte] = + let + data = cast[ptr byte](addr text[0]) + dataLen = uint(len(text)) + digest = sha256.digest(data, dataLen) + return digest.data + +proc encodeBase64[T](data: T): string = + return encode(data).replace("=", "") + +proc decodeBase64(data: string): seq[byte] = + return cast[seq[byte]](decode(data)) + +proc genTid*(path: string): Future[string] {.async.} = + let + pair = await getPair() + + timeNow = int(epochTime() - 1682924400) + timeNowBytes = @[ + byte(timeNow and 0xff), + byte((timeNow shr 8) and 0xff), + byte((timeNow shr 16) and 0xff), + byte((timeNow shr 24) and 0xff) + ] + + data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey + hashBytes = encodeSha256(data) + keyBytes = decodeBase64(pair.verification) + bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8] + randomNum = byte(rand(256)) + tid = @[randomNum] & bytesArr.mapIt(it xor randomNum) + + return encodeBase64(tid) diff --git a/src/types.nim b/src/types.nim index 20a49c9..815e223 100644 --- a/src/types.nim +++ b/src/types.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import times, sequtils, options, tables, uri +import times, sequtils, options, tables import prefs_impl genPrefsType() @@ -13,19 +13,13 @@ type TimelineKind* {.pure.} = enum tweets, replies, media - Api* {.pure.} = enum - tweetDetail - tweetResult - search - list - listBySlug - listMembers - listTweets - userRestId - userScreenName - userTweets - userTweetsAndReplies - userMedia + ApiUrl* = object + endpoint*: string + params*: seq[(string, string)] + + ApiReq* = object + oauth*: ApiUrl + cookie*: ApiUrl RateLimit* = object limit*: int @@ -42,7 +36,7 @@ type pending*: int limited*: bool limitedAt*: int - apis*: Table[Api, RateLimit] + apis*: Table[string, RateLimit] case kind*: SessionKind of oauth: oauthToken*: string @@ -51,10 +45,6 @@ type authToken*: string ct0*: string - SessionAwareUrl* = object - oauthUrl*: Uri - cookieUrl*: Uri - Error* = enum null = 0 noUserMatches = 17 @@ -285,6 +275,7 @@ type enableDebug*: bool proxy*: string proxyAuth*: string + disableTid*: bool rssCacheTime*: int listCacheTime*: int diff --git a/tests/test_card.py b/tests/test_card.py index 504c079..129e65a 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -11,12 +11,7 @@ card = [ ['voidtarget/status/1094632512926605312', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', - 'gist.github.com', True], - - ['nim_lang/status/1082989146040340480', - 'Nim in 2018: A short recap', - 'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.', - 'nim-lang.org', True] + 'gist.github.com', True] ] no_thumb = [ diff --git a/tools/create_session_browser.py b/tools/create_session_browser.py index 40e3dcd..3a05cb1 100644 --- a/tools/create_session_browser.py +++ b/tools/create_session_browser.py @@ -94,7 +94,7 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal async def main(): if len(sys.argv) < 3: - print('Usage: python3 twitter-auth.py username password [totp_seed] [--append sessions.jsonl] [--headless]') + print('Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]') sys.exit(1) username = sys.argv[1] From 7a08a9e1321a511a35bee7ca736db035f9fce52a Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 29 Nov 2025 03:36:21 +0100 Subject: [PATCH 165/309] Format css --- src/sass/general.scss | 56 +++--- src/sass/include/_variables.scss | 49 +++-- src/sass/index.scss | 242 ++++++++++++------------ src/sass/inputs.scss | 258 ++++++++++++------------- src/sass/navbar.scss | 121 ++++++------ src/sass/search.scss | 170 ++++++++--------- src/sass/timeline.scss | 208 ++++++++++----------- src/sass/tweet/_base.scss | 310 ++++++++++++++++--------------- src/sass/tweet/embed.scss | 26 +-- src/sass/tweet/media.scss | 146 +++++++-------- src/sass/tweet/poll.scss | 48 ++--- src/sass/tweet/quote.scss | 149 +++++++-------- src/sass/tweet/thread.scss | 195 +++++++++---------- src/sass/tweet/video.scss | 97 +++++----- 14 files changed, 1041 insertions(+), 1034 deletions(-) diff --git a/src/sass/general.scss b/src/sass/general.scss index 9feb3d3..ce97564 100644 --- a/src/sass/general.scss +++ b/src/sass/general.scss @@ -1,39 +1,39 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .panel-container { - margin: auto; - font-size: 130%; + margin: auto; + font-size: 130%; } .error-panel { - @include center-panel(var(--error_red)); - text-align: center; + @include center-panel(var(--error_red)); + text-align: center; } .search-bar > form { - @include center-panel(var(--darkest_grey)); + @include center-panel(var(--darkest_grey)); - button { - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 3px; - cursor: pointer; - font-weight: bold; - width: 30px; - height: 30px; - } + button { + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 3px; + cursor: pointer; + font-weight: bold; + width: 30px; + height: 30px; + } - input { - font-size: 16px; - width: 100%; - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 4px; - padding: 4px; - margin-right: 8px; - height: unset; - } + input { + font-size: 16px; + width: 100%; + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 4px; + padding: 4px; + margin-right: 8px; + height: unset; + } } diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss index 0c95ff6..127cccb 100644 --- a/src/sass/include/_variables.scss +++ b/src/sass/include/_variables.scss @@ -1,46 +1,43 @@ // colors -$bg_color: #0F0F0F; -$fg_color: #F8F8F2; -$fg_faded: #F8F8F2CF; -$fg_dark: #FF6C60; -$fg_nav: #FF6C60; +$bg_color: #0f0f0f; +$fg_color: #f8f8f2; +$fg_faded: #f8f8f2cf; +$fg_dark: #ff6c60; +$fg_nav: #ff6c60; $bg_panel: #161616; $bg_elements: #121212; -$bg_overlays: #1F1F1F; -$bg_hover: #1A1A1A; +$bg_overlays: #1f1f1f; +$bg_hover: #1a1a1a; $grey: #888889; $dark_grey: #404040; $darker_grey: #282828; $darkest_grey: #222222; -$border_grey: #3E3E35; +$border_grey: #3e3e35; -$accent: #FF6C60; -$accent_light: #FFACA0; -$accent_dark: #8A3731; -$accent_border: #FF6C6091; +$accent: #ff6c60; +$accent_light: #ffaca0; +$accent_dark: #8a3731; +$accent_border: #ff6c6091; -$play_button: #D8574D; -$play_button_hover: #FF6C60; +$play_button: #d8574d; +$play_button_hover: #ff6c60; -$more_replies_dots: #AD433B; -$error_red: #420A05; +$more_replies_dots: #ad433b; +$error_red: #420a05; -$verified_blue: #1DA1F2; -$verified_business: #FAC82B; -$verified_government: #C1B6A4; +$verified_blue: #1da1f2; +$verified_business: #fac82b; +$verified_government: #c1b6a4; $icon_text: $fg_color; $tab: $fg_color; $tab_selected: $accent; -$shadow: rgba(0,0,0,.6); -$shadow_dark: rgba(0,0,0,.2); +$shadow: rgba(0, 0, 0, 0.6); +$shadow_dark: rgba(0, 0, 0, 0.2); //fonts -$font_0: Helvetica Neue; -$font_1: Helvetica; -$font_2: Arial; -$font_3: sans-serif; -$font_4: fontello; +$font_0: sans-serif; +$font_1: fontello; diff --git a/src/sass/index.scss b/src/sass/index.scss index 36a8a93..a19165a 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -1,180 +1,182 @@ -@import '_variables'; +@import "_variables"; -@import 'tweet/_base'; -@import 'profile/_base'; -@import 'general'; -@import 'navbar'; -@import 'inputs'; -@import 'timeline'; -@import 'search'; +@import "tweet/_base"; +@import "profile/_base"; +@import "general"; +@import "navbar"; +@import "inputs"; +@import "timeline"; +@import "search"; body { - // colors - --bg_color: #{$bg_color}; - --fg_color: #{$fg_color}; - --fg_faded: #{$fg_faded}; - --fg_dark: #{$fg_dark}; - --fg_nav: #{$fg_nav}; + // colors + --bg_color: #{$bg_color}; + --fg_color: #{$fg_color}; + --fg_faded: #{$fg_faded}; + --fg_dark: #{$fg_dark}; + --fg_nav: #{$fg_nav}; - --bg_panel: #{$bg_panel}; - --bg_elements: #{$bg_elements}; - --bg_overlays: #{$bg_overlays}; - --bg_hover: #{$bg_hover}; + --bg_panel: #{$bg_panel}; + --bg_elements: #{$bg_elements}; + --bg_overlays: #{$bg_overlays}; + --bg_hover: #{$bg_hover}; - --grey: #{$grey}; - --dark_grey: #{$dark_grey}; - --darker_grey: #{$darker_grey}; - --darkest_grey: #{$darkest_grey}; - --border_grey: #{$border_grey}; + --grey: #{$grey}; + --dark_grey: #{$dark_grey}; + --darker_grey: #{$darker_grey}; + --darkest_grey: #{$darkest_grey}; + --border_grey: #{$border_grey}; - --accent: #{$accent}; - --accent_light: #{$accent_light}; - --accent_dark: #{$accent_dark}; - --accent_border: #{$accent_border}; + --accent: #{$accent}; + --accent_light: #{$accent_light}; + --accent_dark: #{$accent_dark}; + --accent_border: #{$accent_border}; - --play_button: #{$play_button}; - --play_button_hover: #{$play_button_hover}; + --play_button: #{$play_button}; + --play_button_hover: #{$play_button_hover}; - --more_replies_dots: #{$more_replies_dots}; - --error_red: #{$error_red}; + --more_replies_dots: #{$more_replies_dots}; + --error_red: #{$error_red}; - --verified_blue: #{$verified_blue}; - --verified_business: #{$verified_business}; - --verified_government: #{$verified_government}; - --icon_text: #{$icon_text}; + --verified_blue: #{$verified_blue}; + --verified_business: #{$verified_business}; + --verified_government: #{$verified_government}; + --icon_text: #{$icon_text}; - --tab: #{$fg_color}; - --tab_selected: #{$accent}; + --tab: #{$fg_color}; + --tab_selected: #{$accent}; - --profile_stat: #{$fg_color}; + --profile_stat: #{$fg_color}; - background-color: var(--bg_color); - color: var(--fg_color); - font-family: $font_0, $font_1, $font_2, $font_3; - font-size: 15px; - line-height: 1.3; - margin: 0; + background-color: var(--bg_color); + color: var(--fg_color); + font-family: $font_0, $font_1, $font_2, $font_3; + font-size: 15px; + line-height: 1.3; + margin: 0; } * { - outline: unset; - margin: 0; - text-decoration: none; + outline: unset; + margin: 0; + text-decoration: none; } h1 { - display: inline; + display: inline; } -h2, h3 { - font-weight: normal; +h2, +h3 { + font-weight: normal; } p { - margin: 14px 0; + margin: 14px 0; } a { - color: var(--accent); + color: var(--accent); - &:hover { - text-decoration: underline; - } + &:hover { + text-decoration: underline; + } } fieldset { - border: 0; - padding: 0; - margin-top: -0.6em; + border: 0; + padding: 0; + margin-top: -0.6em; } legend { - width: 100%; - padding: .6em 0 .3em 0; - border: 0; - font-size: 16px; - font-weight: 600; - border-bottom: 1px solid var(--border_grey); - margin-bottom: 8px; + width: 100%; + padding: 0.6em 0 0.3em 0; + border: 0; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid var(--border_grey); + margin-bottom: 8px; } .preferences .note { - border-top: 1px solid var(--border_grey); - border-bottom: 1px solid var(--border_grey); - padding: 6px 0 8px 0; - margin-bottom: 8px; - margin-top: 16px; + border-top: 1px solid var(--border_grey); + border-bottom: 1px solid var(--border_grey); + padding: 6px 0 8px 0; + margin-bottom: 8px; + margin-top: 16px; } ul { - padding-left: 1.3em; + padding-left: 1.3em; } .container { - display: flex; - flex-wrap: wrap; - box-sizing: border-box; - padding-top: 50px; - margin: auto; - min-height: 100vh; + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + padding-top: 50px; + margin: auto; + min-height: 100vh; } .icon-container { - display: inline; + display: inline; } .overlay-panel { - max-width: 600px; - width: 100%; - margin: 0 auto; - margin-top: 10px; - background-color: var(--bg_overlays); - padding: 10px 15px; - align-self: start; + max-width: 600px; + width: 100%; + margin: 0 auto; + margin-top: 10px; + background-color: var(--bg_overlays); + padding: 10px 15px; + align-self: start; - ul { - margin-bottom: 14px; - } + ul { + margin-bottom: 14px; + } - p { - word-break: break-word; - } + p { + word-break: break-word; + } } .verified-icon { - color: var(--icon_text); - border-radius: 50%; - flex-shrink: 0; - margin: 2px 0 3px 3px; - padding-top: 3px; - height: 11px; - width: 14px; - font-size: 8px; - display: inline-block; - text-align: center; - vertical-align: middle; + color: var(--icon_text); + border-radius: 50%; + flex-shrink: 0; + margin: 2px 0 3px 3px; + padding-top: 3px; + height: 11px; + width: 14px; + font-size: 8px; + display: inline-block; + text-align: center; + vertical-align: middle; - &.blue { - background-color: var(--verified_blue); - } + &.blue { + background-color: var(--verified_blue); + } - &.business { - color: var(--bg_panel); - background-color: var(--verified_business); - } + &.business { + color: var(--bg_panel); + background-color: var(--verified_business); + } - &.government { - color: var(--bg_panel); - background-color: var(--verified_government); - } + &.government { + color: var(--bg_panel); + background-color: var(--verified_government); + } } -@media(max-width: 600px) { - .preferences-container { - max-width: 95vw; - } +@media (max-width: 600px) { + .preferences-container { + max-width: 95vw; + } - .nav-item, .nav-item .icon-container { - font-size: 16px; - } + .nav-item, + .nav-item .icon-container { + font-size: 16px; + } } diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index d6cbb1d..aafa5b8 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -1,203 +1,203 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; button { - @include input-colors; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 3px 6px; - font-size: 14px; - cursor: pointer; - float: right; + @include input-colors; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 3px 6px; + font-size: 14px; + cursor: pointer; + float: right; } input[type="text"], input[type="date"], input[type="number"], select { - @include input-colors; - background-color: var(--bg_elements); - padding: 1px 4px; - color: var(--fg_color); - border: 1px solid var(--accent_border); - border-radius: 0; - font-size: 14px; + @include input-colors; + background-color: var(--bg_elements); + padding: 1px 4px; + color: var(--fg_color); + border: 1px solid var(--accent_border); + border-radius: 0; + font-size: 14px; } input[type="number"] { - -moz-appearance: textfield; + -moz-appearance: textfield; } input[type="text"], input[type="number"] { - height: 16px; + height: 16px; } select { - height: 20px; - padding: 0 2px; - line-height: 1; + height: 20px; + padding: 0 2px; + line-height: 1; } input[type="date"]::-webkit-inner-spin-button { - display: none; + display: none; } input[type="number"] { - -moz-appearance: textfield; + -moz-appearance: textfield; } input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { - display: none; - -webkit-appearance: none; - margin: 0; + display: none; + -webkit-appearance: none; + margin: 0; } input[type="date"]::-webkit-clear-button { - margin-left: 17px; - filter: grayscale(100%); - filter: hue-rotate(120deg); + margin-left: 17px; + filter: grayscale(100%); + filter: hue-rotate(120deg); } input::-webkit-calendar-picker-indicator { - opacity: 0; + opacity: 0; } input::-webkit-datetime-edit-day-field:focus, input::-webkit-datetime-edit-month-field:focus, input::-webkit-datetime-edit-year-field:focus { - background-color: var(--accent); - color: var(--fg_color); - outline: none; + background-color: var(--accent); + color: var(--fg_color); + outline: none; } .date-range { - .date-input { - display: inline-block; - position: relative; - } + .date-input { + display: inline-block; + position: relative; + } - .icon-container { - pointer-events: none; - position: absolute; - top: 2px; - right: 5px; - } + .icon-container { + pointer-events: none; + position: absolute; + top: 2px; + right: 5px; + } - .search-title { - margin: 0 2px; - } + .search-title { + margin: 0 2px; + } } .icon-button button { - color: var(--accent); - text-decoration: none; - background: none; - border: none; - float: none; - padding: unset; - padding-left: 4px; + color: var(--accent); + text-decoration: none; + background: none; + border: none; + float: none; + padding: unset; + padding-left: 4px; - &:hover { - color: var(--accent_light); - } + &:hover { + color: var(--accent_light); + } } .checkbox { - position: absolute; - top: 1px; - right: 0; - height: 17px; - width: 17px; - background-color: var(--bg_elements); - border: 1px solid var(--accent_border); + position: absolute; + top: 1px; + right: 0; + height: 17px; + width: 17px; + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); - &:after { - content: ""; - position: absolute; - display: none; - } + &:after { + content: ""; + position: absolute; + display: none; + } } .checkbox-container { - display: block; - position: relative; - margin-bottom: 5px; + display: block; + position: relative; + margin-bottom: 5px; + cursor: pointer; + user-select: none; + padding-right: 22px; + + input { + position: absolute; + opacity: 0; cursor: pointer; - user-select: none; - padding-right: 22px; + height: 0; + width: 0; - input { - position: absolute; - opacity: 0; - cursor: pointer; - height: 0; - width: 0; - - &:checked ~ .checkbox:after { - display: block; - } + &:checked ~ .checkbox:after { + display: block; } + } - &:hover input ~ .checkbox { - border-color: var(--accent); - } + &:hover input ~ .checkbox { + border-color: var(--accent); + } - &:active input ~ .checkbox { - border-color: var(--accent_light); - } + &:active input ~ .checkbox { + border-color: var(--accent_light); + } - .checkbox:after { - left: 2px; - bottom: 0; - font-size: 13px; - font-family: $font_4; - content: '\e803'; - } + .checkbox:after { + left: 2px; + bottom: 0; + font-size: 13px; + font-family: $font_1; + content: "\e811"; + } } .pref-group { - display: inline; + display: inline; } .preferences { - button { - margin: 6px 0 3px 0; - } + button { + margin: 6px 0 3px 0; + } - label { - padding-right: 150px; - } + label { + padding-right: 150px; + } - select { - position: absolute; - top: 0; - right: 0; - display: block; - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - } + select { + position: absolute; + top: 0; + right: 0; + display: block; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + } - input[type="text"], - input[type="number"] { - position: absolute; - right: 0; - max-width: 140px; - } + input[type="text"], + input[type="number"] { + position: absolute; + right: 0; + max-width: 140px; + } - .pref-group { - display: block; - } + .pref-group { + display: block; + } - .pref-input { - position: relative; - margin-bottom: 6px; - } + .pref-input { + position: relative; + margin-bottom: 6px; + } - .pref-reset { - float: left; - } + .pref-reset { + float: left; + } } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index 47a8765..4e150e0 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -1,89 +1,90 @@ -@import '_variables'; +@import "_variables"; nav { - display: flex; - align-items: center; - position: fixed; - background-color: var(--bg_overlays); - box-shadow: 0 0 4px $shadow; - padding: 0; - width: 100%; - height: 50px; - z-index: 1000; - font-size: 16px; + display: flex; + align-items: center; + position: fixed; + background-color: var(--bg_overlays); + box-shadow: 0 0 4px $shadow; + padding: 0; + width: 100%; + height: 50px; + z-index: 1000; + font-size: 16px; - a, .icon-button button { - color: var(--fg_nav); - } + a, + .icon-button button { + color: var(--fg_nav); + } } .inner-nav { - margin: auto; - box-sizing: border-box; - padding: 0 10px; - display: flex; - align-items: center; - flex-basis: 920px; - height: 50px; + margin: auto; + box-sizing: border-box; + padding: 0 10px; + display: flex; + align-items: center; + flex-basis: 920px; + height: 50px; } .site-name { - font-size: 15px; - font-weight: 600; - line-height: 1; + font-size: 15px; + font-weight: 600; + line-height: 1; - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &:hover { + color: var(--accent_light); + text-decoration: unset; + } } .site-logo { - display: block; - width: 35px; - height: 35px; + display: block; + width: 35px; + height: 35px; } .nav-item { - display: flex; - flex: 1; - line-height: 50px; - height: 50px; - overflow: hidden; - flex-wrap: wrap; - align-items: center; + display: flex; + flex: 1; + line-height: 50px; + height: 50px; + overflow: hidden; + flex-wrap: wrap; + align-items: center; - &.right { - text-align: right; - justify-content: flex-end; - } - - &.right a { - padding-left: 4px; - - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &.right { + text-align: right; + justify-content: flex-end; + } + + &.right a { + padding-left: 4px; + + &:hover { + color: var(--accent_light); + text-decoration: unset; } + } } .lp { - height: 14px; - display: inline-block; - position: relative; - top: 2px; - fill: var(--fg_nav); + height: 14px; + display: inline-block; + position: relative; + top: 2px; + fill: var(--fg_nav); - &:hover { - fill: var(--accent_light); - } + &:hover { + fill: var(--accent_light); + } } .icon-info:before { - margin: 0 -3px; + margin: 0 -3px; } .icon-cog { - font-size: 15px; + font-size: 15px; } diff --git a/src/sass/search.scss b/src/sass/search.scss index 234d677..444f9bb 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -1,120 +1,120 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .search-title { - font-weight: bold; - display: inline-block; - margin-top: 4px; + font-weight: bold; + display: inline-block; + margin-top: 4px; } .search-field { + display: flex; + flex-wrap: wrap; + + button { + margin: 0 2px 0 0; + height: 23px; display: flex; - flex-wrap: wrap; + align-items: center; + } - button { - margin: 0 2px 0 0; - height: 23px; - display: flex; - align-items: center; - } + .pref-input { + margin: 0 4px 0 0; + flex-grow: 1; + height: 23px; + } - .pref-input { - margin: 0 4px 0 0; - flex-grow: 1; - height: 23px; - } + input[type="text"], + input[type="number"] { + height: calc(100% - 4px); + width: calc(100% - 8px); + } - input[type="text"], - input[type="number"] { - height: calc(100% - 4px); - width: calc(100% - 8px); - } + > label { + display: inline; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 1px 6px 2px 6px; + font-size: 14px; + cursor: pointer; + margin-bottom: 2px; - > label { - display: inline; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 1px 6px 2px 6px; - font-size: 14px; - cursor: pointer; - margin-bottom: 2px; + @include input-colors; + } - @include input-colors; - } - - @include create-toggle(search-panel, 380px); + @include create-toggle(search-panel, 380px); } .search-panel { - width: 100%; - max-height: 0; - overflow: hidden; - transition: max-height 0.4s; + width: 100%; + max-height: 0; + overflow: hidden; + transition: max-height 0.4s; - flex-grow: 1; - font-weight: initial; - text-align: left; + flex-grow: 1; + font-weight: initial; + text-align: left; - > div { - line-height: 1.7em; - } + > div { + line-height: 1.7em; + } - .checkbox-container { - display: inline; - padding-right: unset; - margin-bottom: unset; - margin-left: 23px; - } + .checkbox-container { + display: inline; + padding-right: unset; + margin-bottom: unset; + margin-left: 23px; + } - .checkbox { - right: unset; - left: -22px; - } + .checkbox { + right: unset; + left: -22px; + } - .checkbox-container .checkbox:after { - top: -4px; - } + .checkbox-container .checkbox:after { + top: -4px; + } } .search-row { - display: flex; - flex-wrap: wrap; - line-height: unset; + display: flex; + flex-wrap: wrap; + line-height: unset; - > div { - flex-grow: 1; - flex-shrink: 1; - } + > div { + flex-grow: 1; + flex-shrink: 1; + } + + input { + height: 21px; + } + + .pref-input { + display: block; + padding-bottom: 5px; input { - height: 21px; - } - - .pref-input { - display: block; - padding-bottom: 5px; - - input { - height: 21px; - margin-top: 1px; - } + height: 21px; + margin-top: 1px; } + } } .search-toggles { - flex-grow: 1; - display: grid; - grid-template-columns: repeat(5, auto); - grid-column-gap: 10px; + flex-grow: 1; + display: grid; + grid-template-columns: repeat(5, auto); + grid-column-gap: 10px; } .profile-tabs { - @include search-resize(820px, 5); - @include search-resize(715px, 4); - @include search-resize(700px, 5); - @include search-resize(485px, 4); - @include search-resize(410px, 3); + @include search-resize(820px, 5); + @include search-resize(715px, 4); + @include search-resize(700px, 5); + @include search-resize(485px, 4); + @include search-resize(410px, 3); } @include search-resize(700px, 5); diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index c8ce309..40882b2 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -1,162 +1,162 @@ -@import '_variables'; +@import "_variables"; .timeline-container { - @include panel(100%, 600px); + @include panel(100%, 600px); } .timeline { - background-color: var(--bg_panel); + background-color: var(--bg_panel); - > div:not(:first-child) { - border-top: 1px solid var(--border_grey); - } + > div:not(:first-child) { + border-top: 1px solid var(--border_grey); + } } .timeline-header { - width: 100%; - background-color: var(--bg_panel); - text-align: center; - padding: 8px; - display: block; - font-weight: bold; - margin-bottom: 5px; - box-sizing: border-box; + width: 100%; + background-color: var(--bg_panel); + text-align: center; + padding: 8px; + display: block; + font-weight: bold; + margin-bottom: 5px; + box-sizing: border-box; - button { - float: unset; - } + button { + float: unset; + } } .timeline-banner img { - width: 100%; + width: 100%; } .timeline-description { - font-weight: normal; + font-weight: normal; } .tab { - align-items: center; - display: flex; - flex-wrap: wrap; - list-style: none; - margin: 0 0 5px 0; - background-color: var(--bg_panel); - padding: 0; + align-items: center; + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 5px 0; + background-color: var(--bg_panel); + padding: 0; } .tab-item { - flex: 1 1 0; - text-align: center; - margin-top: 0; + flex: 1 1 0; + text-align: center; + margin-top: 0; - a { - border-bottom: .1rem solid transparent; - color: var(--tab); - display: block; - padding: 8px 0; - text-decoration: none; - font-weight: bold; + a { + border-bottom: 0.1rem solid transparent; + color: var(--tab); + display: block; + padding: 8px 0; + text-decoration: none; + font-weight: bold; - &:hover { - text-decoration: none; - } - - &.active { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); - } + &:hover { + text-decoration: none; } - &.active a { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); + &.active { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); } + } - &.wide { - flex-grow: 1.2; - flex-basis: 50px; - } + &.active a { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); + } + + &.wide { + flex-grow: 1.2; + flex-basis: 50px; + } } .timeline-footer { - background-color: var(--bg_panel); - padding: 6px 0; + background-color: var(--bg_panel); + padding: 6px 0; } .timeline-protected { - text-align: center; + text-align: center; - p { - margin: 8px 0; - } + p { + margin: 8px 0; + } - h2 { - color: var(--accent); - font-size: 20px; - font-weight: 600; - } -} - -.timeline-none { + h2 { color: var(--accent); font-size: 20px; font-weight: 600; - text-align: center; + } +} + +.timeline-none { + color: var(--accent); + font-size: 20px; + font-weight: 600; + text-align: center; } .timeline-end { - background-color: var(--bg_panel); - color: var(--accent); - font-size: 16px; - font-weight: 600; - text-align: center; + background-color: var(--bg_panel); + color: var(--accent); + font-size: 16px; + font-weight: 600; + text-align: center; } .show-more { - background-color: var(--bg_panel); - text-align: center; - padding: .75em 0; - display: block !important; + background-color: var(--bg_panel); + text-align: center; + padding: 0.75em 0; + display: block !important; - a { - background-color: var(--darkest_grey); - display: inline-block; - height: 2em; - padding: 0 2em; - line-height: 2em; + a { + background-color: var(--darkest_grey); + display: inline-block; + height: 2em; + padding: 0 2em; + line-height: 2em; - &:hover { - background-color: var(--darker_grey); - } + &:hover { + background-color: var(--darker_grey); } + } } .top-ref { - background-color: var(--bg_color); - border-top: none !important; + background-color: var(--bg_color); + border-top: none !important; - .icon-down { - font-size: 20px; - display: flex; - justify-content: center; - text-decoration: none; + .icon-down { + font-size: 20px; + display: flex; + justify-content: center; + text-decoration: none; - &:hover { - color: var(--accent_light); - } - - &::before { - transform: rotate(180deg) translateY(-1px); - } + &:hover { + color: var(--accent_light); } + + &::before { + transform: rotate(180deg) translateY(-1px); + } + } } .timeline-item { - overflow-wrap: break-word; - border-left-width: 0; - min-width: 0; - padding: .75em; - display: flex; - position: relative; + overflow-wrap: break-word; + border-left-width: 0; + min-width: 0; + padding: 0.75em; + display: flex; + position: relative; } diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 69f51c0..7f2d931 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -1,240 +1,244 @@ -@import '_variables'; -@import '_mixins'; -@import 'thread'; -@import 'media'; -@import 'video'; -@import 'embed'; -@import 'card'; -@import 'poll'; -@import 'quote'; +@import "_variables"; +@import "_mixins"; +@import "thread"; +@import "media"; +@import "video"; +@import "embed"; +@import "card"; +@import "poll"; +@import "quote"; .tweet-body { - flex: 1; - min-width: 0; - margin-left: 58px; - pointer-events: none; - z-index: 1; + flex: 1; + min-width: 0; + margin-left: 58px; + pointer-events: none; + z-index: 1; } .tweet-content { - font-family: $font_3; - line-height: 1.3em; - pointer-events: all; - display: inline; + line-height: 1.3em; + pointer-events: all; + display: inline; } .tweet-bidi { - display: block !important; + display: block !important; } .tweet-header { - padding: 0; - vertical-align: bottom; - flex-basis: 100%; - margin-bottom: .2em; + padding: 0; + vertical-align: bottom; + flex-basis: 100%; + margin-bottom: 0.2em; - a { - display: inline-block; - word-break: break-all; - max-width: 100%; - pointer-events: all; - } + a { + display: inline-block; + word-break: break-all; + max-width: 100%; + pointer-events: all; + } } .tweet-name-row { - padding: 0; - display: flex; - justify-content: space-between; + padding: 0; + display: flex; + justify-content: space-between; } .fullname-and-username { - display: flex; - min-width: 0; + display: flex; + min-width: 0; } .fullname { - @include ellipsis; - flex-shrink: 2; - max-width: 80%; - font-size: 14px; - font-weight: 700; - color: var(--fg_color); + @include ellipsis; + flex-shrink: 2; + max-width: 80%; + font-size: 14px; + font-weight: 700; + color: var(--fg_color); } .username { - @include ellipsis; - min-width: 1.6em; - margin-left: .4em; - word-wrap: normal; + @include ellipsis; + min-width: 1.6em; + margin-left: 0.4em; + word-wrap: normal; } .tweet-date { - display: flex; - flex-shrink: 0; - margin-left: 4px; + display: flex; + flex-shrink: 0; + margin-left: 4px; } -.tweet-date a, .username, .show-more a { - color: var(--fg_dark); +.tweet-date a, +.username, +.show-more a { + color: var(--fg_dark); } .tweet-published { - margin: 0; - margin-top: 5px; - color: var(--grey); - pointer-events: all; + margin: 0; + margin-top: 5px; + color: var(--grey); + pointer-events: all; } .tweet-avatar { - display: contents !important; + display: contents !important; - img { - float: left; - margin-top: 3px; - margin-left: -58px; - width: 48px; - height: 48px; - } + img { + float: left; + margin-top: 3px; + margin-left: -58px; + width: 48px; + height: 48px; + } } .avatar { - &.round { - border-radius: 50%; - -webkit-user-select: none; - } - - &.mini { - position: unset; - margin-right: 5px; - margin-top: -1px; - width: 20px; - height: 20px; - } + &.round { + border-radius: 50%; + -webkit-user-select: none; + } + + &.mini { + position: unset; + margin-right: 5px; + margin-top: -1px; + width: 20px; + height: 20px; + } } .tweet-embed { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + background-color: var(--bg_panel); + + .tweet-content { + font-size: 18px; + } + + .tweet-body { display: flex; flex-direction: column; - justify-content: center; - height: 100%; - background-color: var(--bg_panel); + max-height: calc(100vh - 0.75em * 2); + } - .tweet-content { - font-size: 18px; - } - - .tweet-body { - display: flex; - flex-direction: column; - max-height: calc(100vh - 0.75em * 2); - } + .card-image img { + height: auto; + } - .card-image img { - height: auto; - } - - .avatar { - position: absolute; - } + .avatar { + position: absolute; + } } .attribution { - display: flex; - pointer-events: all; - margin: 5px 0; + display: flex; + pointer-events: all; + margin: 5px 0; - strong { - color: var(--fg_color); - } + strong { + color: var(--fg_color); + } } .media-tag-block { - padding-top: 5px; - pointer-events: all; + padding-top: 5px; + pointer-events: all; + color: var(--fg_faded); + + .icon-container { + padding-right: 2px; + } + + .media-tag, + .icon-container { color: var(--fg_faded); - - .icon-container { - padding-right: 2px; - } - - .media-tag, .icon-container { - color: var(--fg_faded); - } + } } .timeline-container .media-tag-block { - font-size: 13px; + font-size: 13px; } .tweet-geo { - color: var(--fg_faded); + color: var(--fg_faded); } .replying-to { - color: var(--fg_faded); - margin: -2px 0 4px; + color: var(--fg_faded); + margin: -2px 0 4px; - a { - pointer-events: all; - } + a { + pointer-events: all; + } } -.retweet-header, .pinned, .tweet-stats { - align-content: center; - color: var(--grey); - display: flex; - flex-shrink: 0; - flex-wrap: wrap; - font-size: 14px; - font-weight: 600; - line-height: 22px; +.retweet-header, +.pinned, +.tweet-stats { + align-content: center; + color: var(--grey); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + font-size: 14px; + font-weight: 600; + line-height: 22px; - span { - @include ellipsis; - } + span { + @include ellipsis; + } } .retweet-header { - margin-top: -5px !important; + margin-top: -5px !important; } .tweet-stats { - margin-bottom: -3px; - -webkit-user-select: none; + margin-bottom: -3px; + -webkit-user-select: none; } .tweet-stat { - padding-top: 5px; - min-width: 1em; - margin-right: 0.8em; + padding-top: 5px; + min-width: 1em; + margin-right: 0.8em; } .show-thread { - display: block; - pointer-events: all; - padding-top: 2px; + display: block; + pointer-events: all; + padding-top: 2px; } .unavailable-box { - width: 100%; - height: 100%; - padding: 12px; - border: solid 1px var(--dark_grey); - box-sizing: border-box; - border-radius: 10px; - background-color: var(--bg_color); - z-index: 2; + width: 100%; + height: 100%; + padding: 12px; + border: solid 1px var(--dark_grey); + box-sizing: border-box; + border-radius: 10px; + background-color: var(--bg_color); + z-index: 2; } .tweet-link { - height: 100%; - width: 100%; - left: 0; - top: 0; - position: absolute; - -webkit-user-select: none; + height: 100%; + width: 100%; + left: 0; + top: 0; + position: absolute; + -webkit-user-select: none; - &:hover { - background-color: var(--bg_hover); - } + &:hover { + background-color: var(--bg_hover); + } } diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 227fc5e..fbdbd41 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -1,17 +1,17 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .embed-video { - .gallery-video { - width: 100%; - height: 100%; - position: absolute; - background-color: black; - top: 0%; - left: 0%; - } + .gallery-video { + width: 100%; + height: 100%; + position: absolute; + background-color: black; + top: 0%; + left: 0%; + } - .video-container { - max-height: unset; - } + .video-container { + max-height: unset; + } } diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 91c9dab..66a300f 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -1,76 +1,76 @@ -@import '_variables'; +@import "_variables"; .gallery-row { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - overflow: hidden; - flex-grow: 1; - max-height: 379.5px; - max-width: 533px; - pointer-events: all; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + overflow: hidden; + flex-grow: 1; + max-height: 379.5px; + max-width: 533px; + pointer-events: all; - .still-image { - width: 100%; - display: flex; - } + .still-image { + width: 100%; + display: flex; + } } .attachments { - margin-top: .35em; - display: flex; - flex-direction: row; - width: 100%; - max-height: 600px; - border-radius: 7px; - overflow: hidden; - flex-flow: column; - background-color: var(--bg_color); - align-items: center; - pointer-events: all; + margin-top: 0.35em; + display: flex; + flex-direction: row; + width: 100%; + max-height: 600px; + border-radius: 7px; + overflow: hidden; + flex-flow: column; + background-color: var(--bg_color); + align-items: center; + pointer-events: all; - .image-attachment { - width: 100%; - } + .image-attachment { + width: 100%; + } } .attachment { - position: relative; - line-height: 0; - overflow: hidden; - margin: 0 .25em 0 0; - flex-grow: 1; - box-sizing: border-box; - min-width: 2em; + position: relative; + line-height: 0; + overflow: hidden; + margin: 0 0.25em 0 0; + flex-grow: 1; + box-sizing: border-box; + min-width: 2em; - &:last-child { - margin: 0; - max-height: 530px; - } + &:last-child { + margin: 0; + max-height: 530px; + } } .gallery-gif video { - max-height: 530px; - background-color: #101010; + max-height: 530px; + background-color: #101010; } .still-image { - max-height: 379.5px; - max-width: 533px; - justify-content: center; + max-height: 379.5px; + max-width: 533px; + justify-content: center; - img { - object-fit: cover; - max-width: 100%; - max-height: 379.5px; - flex-basis: 300px; - flex-grow: 1; - } + img { + object-fit: cover; + max-width: 100%; + max-height: 379.5px; + flex-basis: 300px; + flex-grow: 1; + } } .image { - display: inline-block; + display: inline-block; } // .single-image { @@ -86,34 +86,34 @@ // } .overlay-circle { - border-radius: 50%; - background-color: var(--dark_grey); - width: 40px; - height: 40px; - align-items: center; - display: flex; - border-width: 5px; - border-color: var(--play_button); - border-style: solid; + border-radius: 50%; + background-color: var(--dark_grey); + width: 40px; + height: 40px; + align-items: center; + display: flex; + border-width: 5px; + border-color: var(--play_button); + border-style: solid; } .overlay-triangle { - width: 0; - height: 0; - border-style: solid; - border-width: 12px 0 12px 17px; - border-color: transparent transparent transparent var(--play_button); - margin-left: 14px; + width: 0; + height: 0; + border-style: solid; + border-width: 12px 0 12px 17px; + border-color: transparent transparent transparent var(--play_button); + margin-left: 14px; } .media-gif { - display: table; - background-color: unset; - width: unset; + display: table; + background-color: unset; + width: unset; } .media-body { - flex: 1; - padding: 0; - white-space: pre-wrap; + flex: 1; + padding: 0; + white-space: pre-wrap; } diff --git a/src/sass/tweet/poll.scss b/src/sass/tweet/poll.scss index 57590c8..6d54e00 100644 --- a/src/sass/tweet/poll.scss +++ b/src/sass/tweet/poll.scss @@ -1,42 +1,42 @@ -@import '_variables'; +@import "_variables"; .poll-meter { - overflow: hidden; - position: relative; - margin: 6px 0; - height: 26px; - background: var(--bg_color); - border-radius: 5px; - display: flex; - align-items: center; + overflow: hidden; + position: relative; + margin: 6px 0; + height: 26px; + background: var(--bg_color); + border-radius: 5px; + display: flex; + align-items: center; } .poll-choice-bar { - height: 100%; - position: absolute; - background: var(--dark_grey); + height: 100%; + position: absolute; + background: var(--dark_grey); } .poll-choice-value { - position: relative; - font-weight: bold; - margin-left: 5px; - margin-right: 6px; - min-width: 30px; - text-align: right; - pointer-events: all; + position: relative; + font-weight: bold; + margin-left: 5px; + margin-right: 6px; + min-width: 30px; + text-align: right; + pointer-events: all; } .poll-choice-option { - position: relative; - pointer-events: all; + position: relative; + pointer-events: all; } .poll-info { - color: var(--grey); - pointer-events: all; + color: var(--grey); + pointer-events: all; } .leader .poll-choice-bar { - background: var(--accent_dark); + background: var(--accent_dark); } diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index b4bc60e..1db4f7e 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -1,94 +1,95 @@ -@import '_variables'; +@import "_variables"; .quote { - margin-top: 10px; - border: solid 1px var(--dark_grey); - border-radius: 10px; - background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); + overflow: hidden; + pointer-events: all; + position: relative; + width: 100%; + + &:hover { + border-color: var(--grey); + } + + &.unavailable:hover { + border-color: var(--dark_grey); + } + + .tweet-name-row { + padding: 6px 8px; + margin-top: 1px; + } + + .quote-text { overflow: hidden; - pointer-events: all; - position: relative; - width: 100%; + white-space: pre-wrap; + word-wrap: break-word; + padding: 0px 8px 8px 8px; + } - &:hover { - border-color: var(--grey); - } + .show-thread { + padding: 0px 8px 6px 8px; + margin-top: -6px; + } - &.unavailable:hover { - border-color: var(--dark_grey); - } - - .tweet-name-row { - padding: 6px 8px; - margin-top: 1px; - } - - .quote-text { - overflow: hidden; - white-space: pre-wrap; - word-wrap: break-word; - padding: 0px 8px 8px 8px; - } - - .show-thread { - padding: 0px 8px 6px 8px; - margin-top: -6px; - } - - .replying-to { - padding: 0px 8px; - margin: unset; - } + .replying-to { + padding: 0px 8px; + margin: unset; + } } .unavailable-quote { - padding: 12px; + padding: 12px; } .quote-link { - width: 100%; - height: 100%; - left: 0; - top: 0; - position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + position: absolute; } .quote-media-container { - max-height: 300px; + max-height: 300px; + display: flex; + + .card { + margin: unset; + } + + .attachments { + border-radius: 0; + } + + .media-gif { + width: 100%; display: flex; + justify-content: center; + } - .card { - margin: unset; + .gallery-gif .attachment { + display: flex; + justify-content: center; + background-color: var(--bg_color); + + video { + height: unset; + width: unset; + max-height: 100%; + max-width: 100%; } + } - .attachments { - border-radius: 0; - } + .gallery-video, + .gallery-gif { + max-height: 300px; + } - .media-gif { - width: 100%; - display: flex; - justify-content: center; - } - - .gallery-gif .attachment { - display: flex; - justify-content: center; - background-color: var(--bg_color); - - video { - height: unset; - width: unset; - max-height: 100%; - max-width: 100%; - } - } - - .gallery-video, .gallery-gif { - max-height: 300px; - } - - .still-image img { - max-height: 250px - } + .still-image img { + max-height: 250px; + } } diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 19fb3e0..9d2fb64 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -1,138 +1,139 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .conversation { - @include panel(100%, 600px); + @include panel(100%, 600px); - .show-more { - margin-bottom: 10px; - } + .show-more { + margin-bottom: 10px; + } } .main-thread { - margin-bottom: 20px; - background-color: var(--bg_panel); + margin-bottom: 20px; + background-color: var(--bg_panel); } -.main-tweet, .replies { - padding-top: 50px; - margin-top: -50px; +.main-tweet, +.replies { + padding-top: 50px; + margin-top: -50px; } .main-tweet .tweet-content { - font-size: 18px; + font-size: 18px; } -@media(max-width: 600px) { - .main-tweet .tweet-content { - font-size: 16px; - } +@media (max-width: 600px) { + .main-tweet .tweet-content { + font-size: 16px; + } } .reply { - background-color: var(--bg_panel); - margin-bottom: 10px; + background-color: var(--bg_panel); + margin-bottom: 10px; } .thread-line { - .timeline-item::before, - &.timeline-item::before { - background: var(--accent_dark); - content: ''; - position: relative; - min-width: 3px; - width: 3px; - left: 26px; - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - top: 56px; - z-index: 1; - pointer-events: none; - } + .timeline-item::before, + &.timeline-item::before { + background: var(--accent_dark); + content: ""; + position: relative; + min-width: 3px; + width: 3px; + left: 26px; + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + top: 56px; + z-index: 1; + pointer-events: none; + } - .with-header:not(:first-child)::after { - background: var(--accent_dark); - content: ''; - position: relative; - float: left; - min-width: 3px; - width: 3px; - right: calc(100% - 26px); - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - bottom: 10px; - height: 30px; - z-index: 1; - pointer-events: none; - } + .with-header:not(:first-child)::after { + background: var(--accent_dark); + content: ""; + position: relative; + float: left; + min-width: 3px; + width: 3px; + right: calc(100% - 26px); + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + bottom: 10px; + height: 30px; + z-index: 1; + pointer-events: none; + } - .unavailable::before { - top: 48px; - margin-bottom: 28px; - } + .unavailable::before { + top: 48px; + margin-bottom: 28px; + } - .more-replies::before { - content: '...'; - background: unset; - color: var(--more_replies_dots); - font-weight: bold; - font-size: 20px; - line-height: 0.25em; - left: 1.2em; - width: 5px; - top: 2px; - margin-bottom: 0; - margin-left: -2.5px; - } + .more-replies::before { + content: "..."; + background: unset; + color: var(--more_replies_dots); + font-weight: bold; + font-size: 20px; + line-height: 0.25em; + left: 1.2em; + width: 5px; + top: 2px; + margin-bottom: 0; + margin-left: -2.5px; + } - .earlier-replies { - padding-bottom: 0; - margin-bottom: -5px; - } + .earlier-replies { + padding-bottom: 0; + margin-bottom: -5px; + } } .timeline-item.thread-last::before { - background: unset; - min-width: unset; - width: 0; - margin: 0; + background: unset; + min-width: unset; + width: 0; + margin: 0; } .more-replies { - padding-top: 0.3em !important; + padding-top: 0.3em !important; } .more-replies-text { - @include ellipsis; - display: block; - margin-left: 58px; - padding: 7px 0; + @include ellipsis; + display: block; + margin-left: 58px; + padding: 7px 0; } .timeline-item.thread.more-replies-thread { - padding: 0 0.75em; + padding: 0 0.75em; + + &::before { + top: 40px; + margin-bottom: 31px; + } + + .more-replies { + display: flex; + padding-top: unset !important; + margin-top: 8px; &::before { - top: 40px; - margin-bottom: 31px; + display: inline-block; + position: relative; + top: -1px; + line-height: 0.4em; } - .more-replies { - display: flex; - padding-top: unset !important; - margin-top: 8px; - - &::before { - display: inline-block; - position: relative; - top: -1px; - line-height: 0.4em; - } - - .more-replies-text { - display: inline; - } + .more-replies-text { + display: inline; } + } } diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 98a1c29..1e00d39 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -1,68 +1,69 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; video { - max-height: 100%; - width: 100%; + max-height: 100%; + width: 100%; } .gallery-video { - display: flex; - overflow: hidden; + display: flex; + overflow: hidden; } .gallery-video.card-container { - flex-direction: column; + flex-direction: column; } .video-container { - min-height: 80px; - min-width: 200px; - max-height: 530px; - margin: 0; - display: flex; - align-items: center; - justify-content: center; + min-height: 80px; + min-width: 200px; + max-height: 530px; + margin: 0; + display: flex; + align-items: center; + justify-content: center; - img { - max-height: 100%; - max-width: 100%; - } + img { + max-height: 100%; + max-width: 100%; + } } .video-overlay { - @include play-button; - background-color: $shadow; + @include play-button; + background-color: $shadow; - p { - position: relative; - z-index: 0; - text-align: center; - top: calc(50% - 20px); - font-size: 20px; - line-height: 1.3; - margin: 0 20px; - } + p { + position: relative; + z-index: 0; + text-align: center; + top: calc(50% - 20px); + font-size: 20px; + line-height: 1.3; + margin: 0 20px; + } - div { - position: relative; - z-index: 0; - top: calc(50% - 20px); - margin: 0 auto; - width: 40px; - height: 40px; - } + .overlay-circle { + position: relative; + z-index: 0; + top: calc(50% - 20px); + margin: 0 auto; + width: 40px; + height: 40px; + } - form { - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - display: flex; - } - button { - padding: 5px 8px; - font-size: 16px; - } + form { + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + display: flex; + } + + button { + padding: 5px 8px; + font-size: 16px; + } } From 96ec75fc7f358471dd0390b4f9a0d9f547e3dacd Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 29 Nov 2025 03:38:40 +0100 Subject: [PATCH 166/309] Add video duration to overlay Fixes #498 --- src/formatters.nim | 13 +++++++++++++ src/sass/tweet/video.scss | 10 ++++++++++ src/views/tweet.nim | 1 + 3 files changed, 24 insertions(+) diff --git a/src/formatters.nim b/src/formatters.nim index e491928..3ad1da6 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -154,6 +154,19 @@ proc getShortTime*(tweet: Tweet): string = else: result = "now" +proc getDuration*(video: Video): string = + let + ms = video.durationMs + sec = int(ms / 1000) + min = int(sec / 60) + hour = int(min / 60) + if hour > 1: + return &"{hour}:{min mod 60}:{sec mod 60:02}" + elif min > 1: + return &"{min mod 60}:{sec mod 60:02}" + else: + return &"0:{sec mod 60:02}" + proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return var username = tweet.user.username diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 1e00d39..790b3da 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -53,6 +53,16 @@ video { height: 40px; } + .overlay-duration { + position: absolute; + bottom: 8px; + left: 8px; + background-color: #0000007a; + line-height: 1em; + padding: 4px 6px 4px 6px; + border-radius: 5px; + font-weight: bold; + } form { width: 100%; diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 552ab89..58d03a9 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -109,6 +109,7 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = 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") + tdiv(class="overlay-duration"): text getDuration(video) verbatim "</div>" if container.len > 0: tdiv(class="card-content"): From 436a873e4b495e40c7394b3eedaf240ac79dcfe5 Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 29 Nov 2025 03:39:29 +0100 Subject: [PATCH 167/309] Improve verified checkmark icon, css improvements --- public/css/fontello.css | 145 ++++++++++++++++++++++++++++-------- public/fonts/fontello.eot | Bin 9368 -> 9544 bytes public/fonts/fontello.svg | 6 +- public/fonts/fontello.ttf | Bin 9200 -> 9376 bytes public/fonts/fontello.woff | Bin 5812 -> 5924 bytes public/fonts/fontello.woff2 | Bin 4832 -> 4896 bytes src/sass/general.scss | 1 + src/sass/index.scss | 52 +++++++++---- src/sass/navbar.scss | 13 ++-- src/sass/search.scss | 10 +-- src/sass/tweet/video.scss | 6 +- src/views/general.nim | 4 +- src/views/renderutils.nim | 4 +- 13 files changed, 172 insertions(+), 69 deletions(-) diff --git a/public/css/fontello.css b/public/css/fontello.css index 2453575..52362d8 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,53 +1,138 @@ @font-face { - font-family: 'fontello'; - src: url('/fonts/fontello.eot?61663884'); - src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'), - url('/fonts/fontello.woff2?61663884') format('woff2'), - url('/fonts/fontello.woff?61663884') format('woff'), - url('/fonts/fontello.ttf?61663884') format('truetype'), - url('/fonts/fontello.svg?61663884#fontello') format('svg'); + font-family: "fontello"; + src: url("/fonts/fontello.eot?77185648"); + src: + url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?77185648") format("woff2"), + url("/fonts/fontello.woff?77185648") format("woff"), + url("/fonts/fontello.ttf?77185648") format("truetype"), + url("/fonts/fontello.svg?77185648#fontello") format("svg"); font-weight: normal; font-style: normal; } -[class^="icon-"]:before, [class*=" icon-"]:before { + +[class^="icon-"]:before, +[class*=" icon-"]:before { font-family: "fontello"; font-style: normal; font-weight: normal; speak: never; - + display: inline-block; text-decoration: inherit; width: 1em; + margin-right: 0.2em; text-align: center; /* For safety - reset parent styles, that can break glyph codes*/ font-variant: normal; text-transform: none; - + /* fix buttons height, for twitter bootstrap */ line-height: 1em; - + /* Font smoothing. That was taken from TWBS */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -.icon-views:before { content: '\e800'; } /* '' */ -.icon-heart:before { content: '\e801'; } /* '' */ -.icon-quote:before { content: '\e802'; } /* '' */ -.icon-comment:before { content: '\e803'; } /* '' */ -.icon-ok:before { content: '\e804'; } /* '' */ -.icon-play:before { content: '\e805'; } /* '' */ -.icon-link:before { content: '\e806'; } /* '' */ -.icon-calendar:before { content: '\e807'; } /* '' */ -.icon-location:before { content: '\e808'; } /* '' */ -.icon-picture:before { content: '\e809'; } /* '' */ -.icon-lock:before { content: '\e80a'; } /* '' */ -.icon-down:before { content: '\e80b'; } /* '' */ -.icon-retweet:before { content: '\e80c'; } /* '' */ -.icon-search:before { content: '\e80d'; } /* '' */ -.icon-pin:before { content: '\e80e'; } /* '' */ -.icon-cog:before { content: '\e80f'; } /* '' */ -.icon-rss:before { content: '\e810'; } /* '' */ -.icon-info:before { content: '\f128'; } /* '' */ -.icon-bird:before { content: '\f309'; } /* '' */ +.icon-views:before { + content: "\e800"; +} + +/* '' */ +.icon-heart:before { + content: "\e801"; +} + +/* '' */ +.icon-quote:before { + content: "\e802"; +} + +/* '' */ +.icon-comment:before { + content: "\e803"; +} + +/* '' */ +.icon-play:before { + content: "\e805"; +} + +/* '' */ +.icon-link:before { + content: "\e806"; +} + +/* '' */ +.icon-calendar:before { + content: "\e807"; +} + +/* '' */ +.icon-location:before { + content: "\e808"; +} + +/* '' */ +.icon-picture:before { + content: "\e809"; +} + +/* '' */ +.icon-lock:before { + content: "\e80a"; +} + +/* '' */ +.icon-down:before { + content: "\e80b"; +} + +/* '' */ +.icon-retweet:before { + content: "\e80c"; +} + +/* '' */ +.icon-search:before { + content: "\e80d"; +} + +/* '' */ +.icon-pin:before { + content: "\e80e"; +} + +/* '' */ +.icon-cog:before { + content: "\e80f"; +} + +/* '' */ +.icon-rss:before { + content: "\e810"; +} + +/* '' */ +.icon-ok:before { + content: "\e811"; +} + +/* '' */ +.icon-circle:before { + content: "\f111"; +} + +/* '' */ +.icon-info:before { + content: "\f128"; +} + +/* '' */ +.icon-bird:before { + content: "\f309"; +} + +/* '' */ diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 2b2982a5711bffac0cb1aad4690f661e34e308c4..8671134d7bcae39b2c2b05872362415a5a3bfbe7 100644 GIT binary patch delta 957 zcmYL|Ur19?9LIm>oO`b`b@y)WE(`x<TB~e?$%u>^J%p1z^bm*;*_@jjxj)V8G9OeT zd+H&X4`WbBB?!G#10O<q5>vuA5dwpH2rfQ^!-9h5?RW0gI^%P`_xn4)o!{@AyX6C` zJyLfYz+CH$<P!p$Yr>2_KY4uRRS|%^2B3v-R%-@xs5**0njT6Fcz=%rsK3xw4~0h3 z7+27LLA4DH-;Z{WKkfz)f&euTi-ZQx?)20EIM9PHhJlI)>^b^}=v!k6ZR}dF@dy0^ z`WwTka3IuZKNiIR2BAF>8cUN4_5y&@gT6T#N<`M5);FNXB6K~S8qq%VZS`UUb-2Eg z&P37^i(QMjw~(77AA1WxFb*&a%zSD`J%q<AfB-R!SAz{G2s+apB}d7#TWJ^oI_Tnm zXMUeg1hbuRntyU{=_%2LA?d|eN}T3KW(Lp0;mYl>^OGWOg#cH{0=QOK09Ts@wgGyj z1#rbCIlNiD$^y7nTVN}o<Gph5qCvM?09V<<5BvdN4u`)a-C+T!rG4;3Q1VznEx;}o z=K!9rxFLKZ#m~~LZPL6&Hc#`QLWBRPOn{IeP(r8iTF5l!XI+O~OgeIgxGIR$a*~|r zyh3~(O~jnsY+l)GR@#-lZCMVYz9dPUg9>%KaY2@e+qB9&NhwND24a+rH>{sk;XxgK zd*{C@YAu!Ou!x<sc<E)*(MVWdW<_63O5;mE-c68OqsyAMyv?U#TGmOUu(lcKmoNPU znd#Uje7nwk2WpgaSw~Y_+1M4%nLiJ{a4=M!PZt2#O=K?j-r>8-L#)pz(=OEfex?s@ z!!y_>Hu8vkr(U|ifce;x076vQ5`AJ)d?M9LQ)U6R=yg^xqUJ8Do(AFuF&mHE8xiq; tk<rAvqbV&?5l$r%k))QJQUflUx+8_-necE#h$o{dVIZCvEN-aF_P;fX)KUNd delta 738 zcmXw0OK1~O6g_X|Wty5e`81!ZRRaw++A5`mRIHTZLXC^kMRC)GO`BgaX@)jPmC}lI z(QTPc6bv;4aakPbrUpb@6r^qxp@Jx)IOsx4;zF>-JL&5i&fIg(eV=>Z+qzbaDaA1G zt8-O}a<y9DTpcbhy>?~C0k#fE3sbiWAvA~oJOwqSrE`<#{@ellS4le4@i~orH|buA z_H_1kYW(A=p8)RxoC}#`JaP6-_ybLOLiuQhjFx5j5$R8)!<k%R{?cBf0eCp+(QJMy z7VlU;k|1M@j5E3Tyv7F92c++k4$Z`K$?b<7$4ReIf!kVsuJH87&=<gG6;jn^liK3O z;0Dk+rmwMAS60Bt2kt>`vL1?usJ#_%Tqoa7p9Glh-AJv~D#XMAYp!FIk07GIkWSME zim@w6%nfHVS8x7-HeKTVOVogAHDRMD0EM^!T8InKo-hT_N?gFI%oKowxPWz@DS*So z1)vfa=!1)dfb}A#06d_U_5m+Yqr)tq&8YJo*8NO2Wl*JxVUt;3w`U;8f~>3jnEkEn zO_UigSJ*#d6<T3zDfjJ$-~El9*sNF88?KAL;gU`38omx|rGwaL=tsRLgN;Uw_loPc zQv1WFk9z%j+}nRa8U*B`u%nnj3E!E-LaZby(q-wn+%AvEZ+Sak<aOJK?Y%NYbFJ6( o8SlIfnfdh3{)p}j9FwK|O}#4+bMWbzRGv>x&n93j2VScG0pvrdS^xk5 diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 2a64343..31bd38c 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -14,8 +14,6 @@ <glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" /> -<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" /> - <glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" /> <glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" /> @@ -40,6 +38,10 @@ <glyph glyph-name="rss" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" /> +<glyph glyph-name="ok" unicode="" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" /> + +<glyph glyph-name="circle" unicode="" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" /> + <glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" /> <glyph glyph-name="bird" unicode="" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" /> diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index ef775f87308c85319ecd0d8c36791622adf5c7eb..0c04c6c934c632486fc0fc56ce07b794a1fcba07 100644 GIT binary patch delta 918 zcmYL{Pe>F|9LIm}z1eYfb^hqgX63)^-&UK5*$^0I9m<V8cnCy@Y+ZL<$^FB+iFwe7 z^whx`53*25BnVxK1rOp;%m_P1frl=^;2~TrFle2AZ$@i|&->2z_kJ_K-+OPiZ_bBi zS|^XMy(s{Yw*WLB9nCd?bLkd}6weGM`&)jG1L(VGD+ePZ8H_9FzoB{uhabkf#-DZp z2w{LKNW>xo=eKWF0jTJ~NMNAsF?)sn3HlR>WNz$6kNpSzBKq6I>1Zg@ARUWi0D}-r zM#eJal9UIi4xn#JMUt_N7j^aMu?XGBq(^e^dpCQqfm)nj%VcAjiKUJu{I>9H_hU~U z2!;V>fjLh>)Cyc(9t22Wyb`=XL9m#m7S&>4r`)yyEU-NPo%22)2xeEqS$>-p^o&Qt zkZ4|`yJk1_$(3%(ezs<Iu?v$P{1yUSMHk>&<^o*3F4zZH<u1S#yX5e0EuRZ;#X219 z1uWcG4sJA9k_&K^T{?k3;NfuiAz7*mpcePRB|$OZ0yO|TSeyg6yh2d;N(vXnS?{E? zjckF=fe!VZM`Qwogn<$|h5JILFn?4#q%raEIii&jvH3JP)qa&2ZH>ek{A@wlYf^&B z-j*zfQD2i3j$wuR{Wu}Z#P3*Tfut2BECaDi_B+<ce7I1xQ)>TLMX#Y^Ef$eTa|^vf z+8PM!&8}JNDRF$|=kf%(`(QPfTixPAVO_FFgYe;Vs87E93uI<toAAAx83$^Y%uh<= z$&$Szv^xI>pE$U5o=+D6*d1iv^sB~o9%8+AiFTmQ?PnTr4_?9+@sersgSOB`2Fze9 z0tj(o(_?s2o*A)DeB>0+3)8BP5#4;Q1t{^G%i2-%ix$#o`o0()%0`D{!cZ!n7W#*> K1BIZzD*XkEM#mNa delta 811 zcmXw%Pe>GT6vw~6`OU0r+ODg+XrZx?)Rtz8frf@ehfE0Rk95c&S6y|>aVOo(Lfi-s z9*enDkeDptQCjd)LWgc*9qLjPbtoF@P@zFZwtZ*%Im2h(`@Z*^H}C!CH`np4{boG- zEI8Z^h($n~>>JNS;a!n~G=TwY(Clsabq$Cdqbwavj9HAAQO9ZhgTq$`y5H^k3h4Df z>Euu{(ZBCS?OQHcV7_UHfud=7pZYy@?U0$7II>YF03n_F#BjQ=BT>D$t)GD|2KJhX z2}?8w?owZ;j*cYE<m$cZ8tOSVxN4=xG7rDBeE`C4Bg@uk(wbUoT>^G?-TJxGJ_Erp za0Alw>S?!dx*6y=!?>Tf1~_HkV{6539}YG>?>)hM3^9AVwuc3L_DSux-J=x;OTFy6 z_XH~Jv+_We6FBY^2CV`<`U(`$SFkD^1&Zk_+}<1oO6V)V?>P!=rLRDMzM>I93WfVi z90fw)Q2#+Wu*Q$Mpdx=<e<kW3OGkp8`tpVpjdh{sN>M3x&p!}<avMGKLZ8oxzw~l^ zBOmqM@#oFpCy{-$z8p9oJoE!W>A2hQ@vt?!X$yryN{`Fa)ZX^Gad-RWslo*wjs488 z>2dXIyrlOCyOp#Bt~*EO2(<?%kWn(tGTd}lu%Cx&TC`TiZmR-K=)o*L3r$4Dtme~> zYENX9?36F{Dt$^{H#&{izBaCP4@5ZMP+lTM*lvl$cEA`<C9jMbJe#8#<I?4HCRx;% fHqGQnMoV9`vyqMxJvB0r)_YT<{m8$GJPrH<_3pM8 diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff index 63c3c233509e89b741c92971efe70a441b972b8b..e4582ad0ee7148607d5a5e183e595a785167af4e 100644 GIT binary patch delta 3442 zcmXw+XH=70u!g@R^w15xLqMcS6R8^MMan@CkX`}^1W+SY5_%KF&`UT}0R;pEsnVhb zq*sw<s7e<Ulxl9yUH9&_p0#J*nb~`O&NRuE%fW)p_4NS|Ksj7+faSj%UW|j8U^@T+ zoG(VKZgdYq3sdY&N(4%QnF8P$=!K$a#Q;FFOOY7e<W9o8TptAj0R3}HnC|~z_3{h# zq-ePSz#s$wOn_lnilH~s)dK)7wNi2vDX7Tq8#8%RM2faU@r5bCX```_Qg0MG1OQ-+ z6hW7QmA(!6+%LeLk_fY)_%i<?$SmTCat)z;40}THh5rMj%AD`-ib7H}JOF^ultzb# z3;6{C10GRqPF6}z76oFT-GWWvLnNh^vx?$}QNUxGHJU^4?k*k~jvx#cXU`msVALD! zH(}My)MH&GI6QZLUEdu`?R`~mF}4ujc7GBr(TM<gj0}Gg(K3=0bTakTDzn>G3m&!q zz0)t-=qmW_{!8bp@-@dulHu2gZ|#CejiRixr{Y=WFC+GPHPoLS;<?}bA#9|Ilf56m zFjw*Ei%M=@=KH9!R*LFZGm-|Y`5_~l7@qGz${$BI(XYJ|0GFeRNp@VqS~P}dz~&W% zI@O-iuXv3SP3k=@69M@y5SpN-(U<_Hr^QD2UHZ<0E&9`WwP^QO;mwg}tE#`%ZqRBd z;M|KGFGKxlcs%Ub6?xe?h3jGcUnZ)2W|ct$#_0{O*{$`(W7UZg$x;<&%4lorhN`(S z<=r&XR}xL#bF~xd@ltoKFUoX2%>Gh8bSLjT3I5U7*%khk^y?fms>3b97rZ%mh$3JU zZ!eTvNlOYxx57d4w&_u9&2S6jetJDNDs5QH%3a53nf~-dvTM8v!*{U$0P6wxol`gy z=A*e(O}Ba3=;!atp}DqcV;WR+f_}-b*@O;eo(M*zJ4R+)jyAPQUh82TkgI=+tQ=zq zasSqXxX`{J^dLw+wU)abW?a4jhcj5`gx<f`eWraHY(LJ76E#N1Z4!;O%Nw&ffZCOy zJMrPg0GgeTAwhSC;NQ(Azdylo<zFZ~wK($%2mUt0jUT73YoF69IhP&t$u)nBBaYha z_dh7nx*SgmVmM7nxyay+ABm=JZ!0{70Uamdj(vKW<>*VOyUC)OtW&~BBpl0w4XW_a z_^lGyd`oDNCbS=R<{d1;yfgCi%u8vutw_1$C2tuRDcH-G8PmQ~kQ+7ru|Ua-<Qxx4 zczqk1;HL+qF<7zXnor#p=Y)DdrNe_{&1@>_ly};RjGPFp@pTV|I6M^I;Q=d@j~<FW z@~k8MctPGL%xo#W*>ZpO4f{_$m)}y`!R72?AsqLnc;{P#M7XhuE=+fU)y;SV%*N^( zHJnb<8tZPN+SI9KcHbntV49zaNAXl?7e=UHfe*^9IEkd}WhttdQ4MSJ?DhyoyK$W; zekjZR1aU50Udnax-3=4P)jHto@ItCTHo^+5xiy4jne~@rjMpcYuolj@o+958Mob!P z%Q>YtcJq-O_^e9hVISYRA?F%AY47URxwxad^mvJOrr=A58izT%vANZIGb3E?F}NyT z+5L5DgB91<@y-KLdk=bciQ)3B@_sa`8=;lb3gh~R^ND|Io0UbbiJFwC0zA*ipi>yq zAKrjl7Vhme6piA<>P5mWlQOeX-V*Pv%HqBmK5F{JNDaa0%s8;q>)7TD@xz8#^_`^d zUt5@jl<Zlu*G1x#!6mTYcf_;%?^e$q?e2DV?w$;~IPTe!FrkrBkV6Tx?xD&}-DdTe zzSE$Pt{bE5Gy5SMCdav7#d!Cssy`>;zlsO)$|xbaaYlYvI;~jfrvD->oO?g+NPZk~ zX}lTH%V;UI7H2t555t$DO1|$2*{j)Y->E&oc&*za(P&Ia?pOQJ4otw4w9DPCfp5uQ zj4$9tg+k&4B9T~4xcg0lL9r{#Pdsg!crO~pje#oH^~Eu;LS3t%I^Hpes`|*{(mlf+ z$uu_x4{SDFspO7$w6NgXKdNpUC-_1ihV5E?U*RR2@(P+x`IHr)#N#D@JbEJCL(Drn zqu;B{JAuuWhfPk5pz1|fjW|2qFYjd8tKEy?OuVToR=tG#a%C2>wdZ!)o2NpXd_PFa zv0UFvXjkY5*Q=wmKWU`g*zBU}fzumZ79}<u)7b8=tBzEasZQ%mu1uz_gI&+UnKR%I zd=@p|z(t4Hjc|HINfLkM$k)-Amy&eTaUSa2iszti6Q55!dd##=%jGyJk!zC12CCDJ zTQLuV`3WNR%X;2iTi9yzY0??h4gN{+ssT}3U7+2pHg{}-IdEyzW%5+x2yzrG`Q=*G zZ!A2c(0Xn@Z*FFCj_4g;<8|l#-%n1J_J+@ur*8-7ydP^%0CO^)@rdrMyrUX%hgU;e z5oOQL-(YN0tB>CM|H1f`2+S=ijArtUOm}Yvy*FQc@OWVNHJPJw@y^~CPfd&{dORa> zFvkdsDxi+0(qTM_amwM`m(I10on){Og<!MN9_p(+F}LV^P~Xw*DKUvF=kZ~wvh<Zp z%}>QFCQHqs%4;<J!oBn1mD)yMO&(=6H2>V+h_Zax*ymo%vBIj@4wbkNYyah3s>`Fk z(t4F{fGL>JxTW}Zb-&644_kVGoX!X&Sp-&e5xvF^*h&r(R`WNBhQ*BQ-1<YFN7&}R zNA{`I%gu+V1FgES`R&eHm4cS_)l#nY{3I*aBkM;>0jTqAyF)nz!%Xy4GxxFWN)5ku zk^EY}jPvA}&BMexk^mj8Vl-^{MO&5s`pW%nGw?M%v$9f^<4>_6a8kA{AHThAI8(p< zN!Z%X(PRj-eZv#chHOX3&(k@=2kg?@q{d2{9P@y=1KBVdwJnLz*MI0V)v<}b7m7HJ zq|yqUU5;TWd*PbwSPWRMRIE@S%3sX;ae_8e03xYi47|Y2rj_Bw+h4WM$TSQq^oIyc z^#~U6^lPWRstzFpo9>cii;b7c+Pcdi5(Bb!`2q2L_HSmhe^L#RY^<!8u`MKTiK(0E z97C77iUa!iKQvCupsm!4|9)Ng=7b@RG;qF&gZ2NOE8A?gs!@|t_~Ldw&a{v%PO<1) zmhPGul1}-q>XR~)Rv~RZP|=|OuotvVyY#KmS{|R6l8hHzs9Z5fe<V}k%f1XM=C)eO zm3JC`@q?~B?~Qa>)@~6rF<5^iwgIu5=)Col<aq~vIgrk!5&SrQS$r}zBfmj;%W%T- zVq74B<)(&CrN|uxM-wseTXs(D4(m9iL@1oiMuN+LKc~J`QtDVct^4QR;iH62T%%I# zHlzQs?5V_OE!T9B^?U#Lg*;2AmRVE9d4`tK{9E;aT>|$-^LgZsN(gp@zWn?hjTCJt zCMX0XT9F(?G|Q3~NHFDhx#7=fdIM9YY4vczbXGF4Qt)ix^+BcAGpiPmM!}C(<wrY7 zUNbDis%bkdCND}X@BG$Sy}G7uo@t!$Om<YDplxdWBqs0WMdgTQ4cV!btKQQ0rCtP6 z?X4@iYd2>KgncF3CR@5h<mYs@(e)BZ8G9URdm;#b$h|1|;alYiXv9nz>U<s5w( z9no*#;vTVT9mMKBualDhd)7cZO1~}1?)|{zjit9UYZ=|$zY^3o&#vFZ{8Hu*IsJUc ze6f8nAT{@tC4$OVQ)pA94{OS+k3DJ+K$m*DbHq+z*;^uGwM^>;v7EZh*Gu2!ypMSP zUTeZ(&`<8e#3s28U(=@8>#*RTQEn?Iu%hTW>kO8ex2OW&O8vz9#I<>Wc0=K*P1cpC zPhIlPaD+5v<m`+4uaP<5t+m_?be2__VWTc8Jks}2IG2u^fuGl5Qn#J97h06aV7XW# z*5YH(ho^T5bzvRvUSXLlr1OQK8%fdB?RNv|A?dI2Ew5G!LRaNF5M=E)9tO2o6NyD_ z1+D9OAV-&Is2=!ax{CKftI->Ct&5ut+$q$IgWt}mjhIoNKY0e1q%?T_Jj>}}oPXig z_MmTVHB)@nm22)5DLiofr_w#Q14B}iv#jpvpQgvVNlxd8{4;Fa<UHR_^;m|P!CD?m z-Cju<+ROTRll%OAi>eXydVtj5{(Xn(jE$BoGWeyFDirwN!PmtFaLdkEyuNyZ>QdZS z>IRbKHJMygV#dkjzoQ-sF3dC!)wpvJrGUwIXbJHDHO!|)AA5RA$>bm8Q5hOW1RG44 z0SjcE(nwPt#q{>{G(|^6snH~I0SeDYW#~RIAQlKcJ;!lTQF>%Dw*DB(%4hf#e58D9 zN=YDcE9sn^^NH7Rl+p{}zrd10nanAR0^kDt3v7YtK=GhGupGDw0zgzC@2CJOPpSoK z6>5L#=QR8@kpPI2%R$)~s2T-fc_6IN&V_e}v_2JNXmj^b#eCP`;|5(34DW&SQ!qP& zxRm#lZAmXLpI^g!L0bL?1Rk!Y*5se4P_y+6)1h@=hP-QWt}aE>8$$+a+h_SH?xZ~1 z4ur(~(vkNSbKEq0*mT_Nc7Leni1j_Q!_2aybb<~sGkMI$Lrm@NXHrc2>=%0a1L_@G MX=MP&xXu~zKlz+IHvj+t delta 3326 zcmXw*dpy(c`^VolhdG~_%4s8~%pvD<Vpb@J<l`{R`4~kJo7J4x(2yzTBBXN6c^Tyt zAv%mo4b|`=<ed1f&*S&I9*^hazOL7O9q#|`s{+xiE*5(pi3HdHmPs@Nf`3o=Dp|}H z{{#R)*ipf755H?Max6ccb;HX-fCUg36%@hJ@&JHynI-Z0B6j10Fws!}z@5yxhWsBw zLE+Z}SsIDO008h|J-!_O861G|2LRCvtelf9^wcL6J_oZzmiBjs918`mWIVV$I3gyF zC04TpGZqe(e-01BBmMp+GFh?eUyjvF`A1;lScApHSh3t+*z^VNUBW~JurwV2V2fcr z^!4lFW71KP(JWv5Z@+97)W#*V`KT)atXc^JRvgcwD6z%3C9cTN$3GX%mWa<eB|^~T zvz8?&6Ny*2B}xXs?d#Q3Mr{M7>(rD5ljHUwn021;o9>^i!S8MnNmY+rYCar0OJ$Vr zdijC1x~keeBm(vuMee*;${f*~{>bE2H+C^Dj;{Fe@-8pf^%5!m$v0Wt^+c1SO|@zZ za&`-yXM!*j9t9gxNSqRhPe>h!fWXy~&x~RGiJH5y82@a|bre)ek@RUX@$q2e$^a32 zGFFY`{h;CGH2`SJ1#xrXBf~vh#k+sZY>$m?HI*Kk7-!AV$F`zFl`d`gnkSq?QO8sD zk`?&T_wt(U&%Qq{)&V)iIPH<5%N=01C}vdi2&v-YVsCvKZS;`)PA~yC3r2}P+G*}l zK-N6ew#PzU=AxcD)6Fp5=G!x|``FRl;x9ivdm&Tt7T%RVMUejZ+2h|jp4@=U64_}! z)|^N>(LzPOO59oq;`oW^ah1DK&K7o%x`0q|f-HuhUy80`=lFLN&8zEo<uj?{|0Gj< zuADskQ&esTGZ~Y#gDPA31<Cst>}et$TJF>gAW_$Dx;!$tX8bipDk&**ICD5D>Ce(; z=#xV|^wVzL@i<X@D(B3j522M#{N6T&Kf6%ga8>Tyr}19E2JyGORKEoqQ*1_gglLfb zht0HlZo##}O1@y?wA0gY<8~da9v_WqY+mA(x*f8HfAHbRlE!oJm?@qed0^bzZT+ED zf{xoW_9e$rE5#wSddWdEEJ!Ul%E<4b%0n*8BNeZU6z1?BUfoe2nC2KoFsnMtAni`( z%MGHmmAb?@d5ZaF?t9WBEmeo_!d>!)^Aucev}Ox7cBMD1Hp+jipQIV40*bp(Mx<fy z2P&_{X-3vb(_2lPi`(C9EFn6x$In`Iz_sgn;}t=|gXnJV?8xv@PcFtDfACi(=XH?* z^@mMzvv`fqWR>Yoi^f;+Lm6_)cJ%N~PLS80z6Q2ZOR3iEd$0_LO?lu!>j5oiLsUcw zS5bE$Mz#t`NhhRpTkt{9Fc1t@&oRNC6m<fUcE9d}%1`EC!IOr11pL5lAPZ`C+padb z{U#(oDX%F?-k&bM0k$cys(txd6JO~m+;%k!uQUnRF0Cr%?k?WxTdeG{&H7TRwY+BA zFyX*w_UK}r0RQ$!cayrQgu4E@H}RjgVuK%s7>9j%qjPf75#8lPZ8mmT|3!((uIo!( zhMIa&ov0lj-W!RyZ_l{So_<qsq^Z4q8D$bVnEJ$(u5FtG2~k{Dun$~J<5<MJtZYkf z#Lq{EjZ_kPJzsGPXDAHW88JAdEX>mNrLshdNXK)Tj$$b7V!OhcxP&g#Hxdz<E&cAK zC}rsQpL@12NU66rkIzxe<F_%d!h0uq86{VE#>z@2k{CK!lL7f7A3}Fuf0oAKr2E2r zyp}$F!bRLxP1-;px?fQi{GaQkmA?MGig)rOD@HPYaObaT?A;EAI>u6Yb$v={Ax*D< z%W&s7c#H)9LSnZdN|||U2W|znxB5b85x#Gx%=0Zrw7EDZ0Cpw&`-joDzg?nzX>Nse zS()ltr17+{pL#Oqj;)GOD3=6A>U2j}-}Pz1!XqN6h=2Vr-7m9j&Mlt5yBFt?jt9Ls z1ifCmT<-hw1&SX7#R);g^EGR#okfg$Y70Paas*`_t_-<!o9p6PiwC|vXIriIm*W#O zp$--K$E6!cA^_upR0i4pZqDO0(pjF}I?7{6;6MF*u9`2}0%=Wu()QNMaM`j?WeIvH z6Elt4uVlL06nOE#X=`nl1g<=RH!f6nq}G-S*zrN0^*^vt`e}FS<CsgpFCqO4OnYno zaG6*6w4w3BedIsRr)BEvd`E<HVCSf5uK8BU959u}LFl)h%ZtbMNk(sib;RuhPGd}k z_}VI32wt-bw39g#lK~Z+)-@Rw39tS*ES#}W;FIVANn6foWG?h`mG%zI;oYNVcjD;h z(;}TuBATxP$9CcWG_QHhq2^3^E=q?|Yc9BEZe6Vu+-wPgE~jr_3hVnaRT#s?P0sIN zM1;!3Z(FTel7Sa|ody~=e?f;9K?r6wygT@qvuNmC!ABhQ=Qx}6N?Xu0zvNNx*iJ^( zO0v=NHT?t9@JdK5wo}F&FW#{rH8LzYWPzh_6WCwy?Iu;*+U{u3Q<9^39AMx(=cAB% z4NBEp22<t^`c6~9uOPAPE;ZEK)|ob*vG*6Zm?2G&&XTs{Lkm&F>_*Rkk>W0s`0}D@ znYAOqY3#|HG9SyjZ43_O&*sSsDdFTDR@!n)u2Ra2ldI6PdB7N6b*ZG{cNRe{lm9t^ zW|Q(=@LXu>QMH@ed67_E_jd_0W2U+?goFwGKEbX<)4J`F>CjKP=ZRi<vB#ZKbl+z# zo3(Cw1Ws^SDQQi(eJP*w%V@d+)xr#tIWA4Ub=4kT-sXb$6v_4RxFH@Hx5v3BDrMY* z1vxqv?)zK9+(#7f`l<aCy%}KrT7xh(>i01jcwkq7-KBZ`8(r-UI)*V}>%Hxp@1j2@ zVv|a2vO3O?mrRe#^vHg9?>_5qeZM-yzemw&CZ=jfn(4@v$qaT4s<#omYs!cc<yVfD zoW5ZUhiQjAyh-928-L?DwD3l_EO2G*Zq|v7>O^tDos#lD_$K}f+}P$-S9YJqiTtX* zi*YULrIEFEy|Zv@7xBd1%s`7dQMVp52Jh}x5y9$te%ffLl8%|dGwI<my7hbHHKD?P zpPCoHu9$nGmC++mtLVEU<*58C#OsWenNw;;zqGJm49A>&v)gLQ{;2N^f<Ck*L5_`o zJd$3Ni@iPShBq{H?eTr_po{m)9Z1W5NxE>2mH!DPVPR!LGVXAe2)ls9GqqK3xwJNz zIB}*Tip=W_;cBTzC3SHDBjk!Z{gVk<(g~QXQH!UcZ5NTlgvJh7o9csFyLA-I{b9b1 zG^M`lgzApv`Lex(ky``IHQwmtB|gr5y+67uxiAGAyy|8S-6|_b@zc&_nEEP`zsqqB zW+&fpU7l^K?pKMZ9oJ4tjmYeuDaKI=Ep+3Riyfq81K+Rgzb{9JuI_r=VKnAO3=LSA z{2rlf*|YwpxCqI5-oDY==wD;hdPCzMCRgdIl(b>>-AMZ`Po@zXF*aIPa<8;=dTKMN zec?qAeoTLDS0Bf-?P@k*d+va!)AxP273Y_Gl{Z!l^&s+rw(nXgyiHQvYQ-oXd%H6+ zuhVSWeJtYWtls>g$GLatb_aP(fP#wGU~0QU!Fx%d)dIEWgYs+SEcJM@0Y*lyOQWaf z2~G^d@>RIK+trYJVeh_O|1A5=mw^H)5rdTRr9{ezoz7eN+Qxm|6dvc+4uNI$=cLr} zeSX#6600FQ&I^&PuA4~=qWD14K}7_?y9wP}Ht&kK`z#}ZKaqx*G$)%(Im^<?!!3^C zSJjc@8k#*R$uHa^R%lTj=z6Hhdo{t`=(g0^Q=gmTy5xo!%chUs__+$%BzboO$d`Ck zit1}Mo_1%le;Ru9?eoK({%1d)&9AQ=+`SQM!zJL~A^7qsR-w3KWn(jCuXckpDTO<j zE97EAKYNHOL?r6-b3bcenMErKNgKNSQs6rA&HRRg93A*?C-w0GW<PuL%=G>Ghz<A} zl*lHAiCc8g6hs~#9-8O&-7tg>;=lR$fHFV;1uek+|3w0OrF}HoeOA)p*a=QP3t=%i z5Rk9Ld4kn6v4=)$CfvMf$eAt)tnn&Ku={ezUBdYI?DzgxVs4jZEsMGZ+SEO@p(zdc zhRG#xg290HIzMZ*{?GIPAD{$SWCO9uv6X;iIYCGe4GaaJ1HWa5vL~`{aGc|q=KQ<A zv#}6sB8U~U;VGta41^Rp;)Wp=HyZOXwjGhL{PH~fAk5LivfPRz^T<&hx^J4PfWd6? zNkrTIKKr~#8(Y-`Z7oXO-IF21b0~R9lj@<u>~#d+Gu;x)Am8Pwd;c0Rho($yoWh_N r25iU4>y~-7&7<KON|F`dsz@p-k}{815Z5fRY!99)vsL6)N#Onm1w{Mr diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index b7541f0278434295a1178b8d5a6adff4deea570c..d2c246d1aa701c85f3438d4d3baa7e3a245aff90 100644 GIT binary patch literal 4896 zcmV+*6W{E2Pew8T0RR91023ep4*&oF03@IQ020Um0RR9100000000000000000000 z0000SR0d!Gg&GJB37iZO2nw12mJ$mr00A}vBm-0gAO(d@2Z2TmfgBrtAyo%z*f;<- zb8SadX)@XW9}=`NLb2b0whC8LgHzUc5`?&*CiWn^qifwJ<%Z;LAzs8v#EOFKPNn~= z52hO;R+Mf|woCCXPhLa2<8JtwTesd7dg#JeOFhceLtf)^<{&h8${&xAWEP(9*M8^8 z7tE4Z)1+!iBQ!h_iELv?q$qYP($_$pc9JCn$`~M1pm7;g0Gb|fn)}<r`wvgs{C9^{ zXC-c8HDDEHqD>hEW`dRM2Vzuh=rRlYewyy?Yx?e;c}zo?YeP4OG@@zHsWJ==q(LzJ zL*CxMJfyZsB7wxQc1iGj+R}3XNP#c^RKC#$$bdRZ*k&mx`PNs|F6!;7rwv<bHt!Qs zT4`B-@DvpxjlLo$`;Jo&O$kJx=ii)l@rcr4!Bj}$`Pp8N#NmtH)x_Q|23*BtjKS*$ zuiHu8*N9Hpz9mS;)}e??_4iA?@4%hjxujGJP&6EWi(<wD7_$HckOPAGzq!)>|2H`T zx3<W_pk(J}C!4&?B#TFwJ<gR9RVD`{6rm*A5`aBdG^R>b$PQzdG=m4n;WTt&iq+A< zO#X6{cTj*pW;uZ?JsUfbS|n{Qqe_7Gh+E9=)neMr7$T)B3<l88D(y@23Uv}d@55Vw z)6wVE3)#Vdh8wiM-g;L}vj4e1QFF&nN6a?MUqBJ;G{S=+&9E1BTiu<X97w4|Vdl>F zr@`n@m>7xpSq7s&!{s9nf(skaSJ(s$*L%kG0rFoSItC^dHV!Tx0z#G?c|duQ4}ysm zw`f2>r9uJm(T?~SM|`YA5F|n%5eA6}NJK#*1`=_QNPt8VBvK%e28j$v5O98cBK4#1 z6Qw_HHbzkw-p~CBQt%iCQRLk}OozNfz5g6o++g&YCel2<Kt2E0!zif7x^JunuYlTb zA%Mv{2uSn9L7`TyLxI#kmBs5F-y@`?<O^Nw`O+WQhjRN+uno#yu3<NaEp5^L<jI=5 z7Rp}hE=}#-6)4rWp@hBU72Je_*d{__s}0PfBw_}_-b11z@7G8z{LjGR=XNB{{$M;n z?FWnEaq~-f=rzrYa&xm4QYTrROj&)6Qh<|dMp$29cQh+!so?(%GLhyj7cfQI$rLWl z2`$5NWs33H1)z@2F}Mrhyd-RI8cbb(_dNd-VT?WQKOU+W>l`5|>dZrYi6m4X9UL+c znCDv1xE|2VqnkI@Mo4z3vStL5TeQjxg7a#dwDUnF$kKuL?8W_4n)LtYR4TJWeOTNG zY2D<_7&O`_$ooK~QHh?8(>XL?u-l@gW6&SSFs}k^FGH&dq=sa)p2L7JI-AS!KNczp zoI>V^E=hgwTqas$8XWi;YcyajYDBgj!R1L(K$s^PG)0kG(n)M+V>;;uQd*TLerxx` zUmM47g7ODqiFt52H`o5tnZy8LJy@eQ&RB<sLR*J?e&aX<q)!BoZ+(Z7Yd#_t@D7FJ z@S!z{q?Lax)J;+FnWTBIkzInO4e)L|6!z1P4{;hj+(Fp95#DW862{6v*ms12`TZR; z@UuaR$4`+Tm%1-r6~#=DWfc@RvdGR4bKks4oQ~*O{bI~)g993}TD?8uaU>WDoh(LE zOQXMw7KE_qLkNz36Ct%17Nb*W5w-(?c^TlE$1#w-q`&pmDa_$2y!XOGbZwAQqS1;U z-Hqw!NKmj;MW_*hI#Fnl0!?DjA`Wd*p+g#UNrxU8^%Ye0Cq>Qgwu4Fq`P@;MSa}kW znJB*_Ph}32zeARyLs$~A8B%^%I9Nn9%uq2$#R3&eRP3YT02PO*I6}n<R2-u=O+yxq z6=uANdX}EHR!^TSCYiR^_$+SBBo9x|F$a;6Y{-=6S>3BIJysgQVLn_=6?gd1+D^q> zuW*S6;c*nuUXabw<C_*CFPN@dLI<p&pWN0Vi46mm!t!6VXFFKd*|I3Hn1^H;vM3|g zD*^c*<XAy<bJ4L%=0G@pdVw_*8kYM~$q3kv(*CA-8`<J!3H#=NdmZN6RUz@U8OJ)n z^3R$`Fkdy2^u-QmSnM7&(<ZXmtb);Olt@*YCaC5qtA+Q6lbPjh_{sEmYP=hdH={qu zcAAu?pguqyCRA>#G9Bo`C!`ZFrdr|5wVI7F8#q&WvGi40<>ker4)d&0Ob(%r$pqG! zBrg>RJryY)Fx)dd!fIGd0l84Teh^(KnbzpoRKp-50*{rtID4GARf35#hmgl8vd-An zI0-A6?S^>>l`N_?Mnwus5r#~t-mUWHvL&p+C0e?^p`@96bf?7?Z`7IDYMLhnL%*(s zN~J(%)7Kc*&1MX;7^^ZXQTJxl-N`r2`kMEGEj9Hn?}POe+l&WbBQ>e!_O+hPDwzkB z6|=A5umsDtX-B0`$=K%k=EiC2^A@ReHfrw?iaFT#aJifFe=)LPE)GA0ZM&$m#@b4m zjV!`(J@zVvh?9=#GOGnH$RhmXslBw#CtK{?=GC*T<=)twwV$IvnglDvxd3YcF3*PL zLzPN~O;Hq;yn-<&P;M8Gi&j)K%utZc0p=)~$72l@IDldfutYVM@l!Nbh>8vb)m&gL zkgT^zQG=i+K`lbIlfx?NphaD@sE3yH@K{SeA48A>3@IyO2`eeqYS7OG4gy(2zyXc| z!8jK<2{e6*B8T>@*+K$UD09^0LgJc-dUJvTK8vMFV5I`<3K<dNMj5zOhP|7j4toSu zJm^R`s=%p2W>k1qCSH_@sWS1Z1GuTcT}3=#=EA44@TDw#D+@n5fUgStRGR*1m5bUL zN~3PHtd*}E)EXP3@1i#-yX%KX>XW$66cE141MQp`S!0}Lgbv*llnL#NEuzCj5ugA8 zEg%DHu&Ax*^~eD9gd)J0IBN~hb>vog9-d)n;?il)jHjosoVJUZ(`N=HGt0>gmY!;W zjc4j${iT?uO*4G{qT&9yJaFKU_^wmQI;JR)mco0U*~acsL9s#Mx9ft)1yLM%UhFN6 zzdV^b(-_L_+y#)-y6txG9yIe0#XBK87t7+eW!tL4@I$_}2SQfp*;2;%Bdf`!*J^A( zR!|&__!n6$`TiHa<n!pF{ef_Yb{-nnZL;S0_bN<@G0@1z5pocda@Z5~sk$}?GJTv= zQnLw+qag#k7U1@XwA={A^fgvhhFe{`<7D#5rE?h(6dG9QWo7I(@`yCB>bi^=o7u?6 zH)dt6p8x;(cjC{Fe<WEC0iW^R7`5yvfe1VqUWncePIlV-K)4qp?)kASUbv*Mi-hbJ z3*3~5TsMkPJrtiG-)L*yj|J!?C(b!mz@LD2enAK%z^jP8v<!>IFmyC4Kec^Zw5?|e zRZy7w_JMZ3<FibQa4nYrk3`@g4P|_WeRSdBfW1g4+@nx@cDY6MV(9ho!lWEz0}JY; zZo>}?1k+$1hBR7$sh_HLmjZugiIWe5iI6!zxj39mPDg!TMrrM{su<(rNcMg^1G%9F z0!cK{4I|--)P*hDSV44!aBDEdlS*SPN$GPAOW7?%0PwhoWO$6d{Mz$z-rKJ*d_0rg z+Wm!>HwaO^D}Zt+i9B;*v}R<tV_9(;_V3UiHETk-&=>%E*BiiBYVsIxE6FJ2nBQgp zV=E}tRn@ts&ehJjsOPKCu@+<&!FiM5n&Ok<<C*-MoNp}uNZ2<PWs%vSz}7>DngKnI zSp(wlnFM&x-l-;mR5$V@u~CnmmDG)yu<E>J03mp}9eb_MU{maWkzYhvP`W^vPY{!t z$iq_`pRuPWRyTJIc+5SjB8`($JBHtZybvbuj{ctypZ+WAq0@Vc*ldD38wulgT<1is zkPvnk$KS%2^NLo~Q`Fbz<K^#!sOP?Ict^|2tB=d`-6;978~LMu2a!V}x&$38;Vnas zAyJ=exTVk2CmQb7H>D0zci0me@jACU#Zc&J&uI~Gok>65#9dt%%Mj-X$1tu&1k<$& zJLa}MK?>6*D2Ndhzz9&s3o@t5nB0km99<*9HAkav*bDv)GM1L3iR$VagB8LY;c;PJ zaD?5b6yXFT;}|QK3Y`el=pACP5eero7%rrzjT+L@9)UD4-<;puh1zSMH;%BSK_T}3 zAE9XfMSD%TWq|`l;`TVZrH;Pd9?c%nF|9cj7n_CN+&-<oCpH#+(<&##gt-hQ!4TP< zz!a1rjTI|dzA}U6EnR6+4?CZ6)m4;0%&?|DkStlcvW1>qzEYwVA(e5}P1KW^ZY{}S zC<}zh>Oe}UzznGfH*=T33uSkl`P<t*2BFpT`oPK>*tcCxH1T;hMQmtTtf*LSr~@M! zi_?FKaAY_l&!})qD&qV*bxEQBf+1;5L#YSSR#JqhEWD-$N9D#KO@nL0-LHw?JVOVY z4|*(ofenro7+7!3jKbA1@T?Ic6BiI@T?b=gF#@$XMlJq4mI=Bt3rifqF&TK~WnmUB zC=gp88^Xfxy-Oe^wvtFq5QPRcBvL#!;&iG09{%ANmH#;cnH7=YyF3lOjUx*SDAqJ< z+a#wHxUMnVB*lo**GDl*F{alv|Mrq%DRJ0xn_9eGsG=hO-o3xZe-#wo8Nc)M<HH>p z&C1rS-uqt-Y*#`-&Y>fD&yVG09TI#|a)_7rd3RpLA*69vz3455&AuZ7)oFN(9-5zW z61#jd?@5Z?!(CBiO;pv?C!||?pQVM2PL*R$s+ce(hESk|C$gtaqP4wjO(HUEYs;}* z<=Ucmzs5!fYr-y|S+t-FiC!vf%K}%)uoMs2>Qk64)x3|xdnFvA9GI0g9L?J&=X#}x zAcLF-0Mh}_0pLDEt-}V<98g&(4L|7p@E(9G9)tgXRzGA5A9Ar2fi;6MCTSdt1{+-h zzUrMj(9JHvndTMDfIZ-{k(-s2o^#=p5cnE3Xl_-~kP!yoAa7EnUV|e}dGYWz@I&7= z3Ox@VW~sD3B6;Buryw{2_*&)#&CTjhi61Iyi;Nr*9wT5f=sO$+w*k>g<R$VmY;G(s zO^m*}t!C_N=?KO%%{_<KHFVz>XBVu^u(Q_noQ5pR-eHmT9Gv0=P5SFJZMW-nf+I_d zJZtxsV7F6my`yF$iK8&^h1(LFvMJP!N}8zTDI>*qBP6^k1T=-9Tj3F}XZJQm4GUa| z;=d-e6H(eC!G-y`=EB|*b@>GZhx|LTXiV!tK*Ru#@MpNk1mSV*i|J}mG-{&f_rfsH zbGwpO06gIXva2vtS`U$WR*G4l&5XA9qu39A6dAc+qUcDyoU)vXYJn0~rE^XQI|Sun zDvSBOF}>2?bP7dP4+q$}Moc;3xZRUU)~{ztJPOS`6c;w|i7W}}0)&YhDHi}755jF& z3x_Os-euWp4?^$)CVNM9DX=gYAxeB5bx;$=_H@@%li`9N<qL6~#Dlx`%c9+Cs>YK+ zqm~E-oosPoKEo5T3)rxP1%j7A0-m%Q&rFZ!tNFCo;HFz*&6D;C-xau&gJBVa6qqE_ z3!KAGVm}Itz+`h(y?sE`2;~K$nAA!#4tIu<e;|C2^Pk?$iyXjB<{u%N_=bRaBm|&m znlxXxq#n_R=zHP8U!`#zRoy|U=3XV;CWieE4Ug?46wJJlO3-$kP#czok>*83^F{5@ z&j%rJDDU0TPW?a88Y9vUU-U5h5AYdmaHC7||G0B*jtT<+KNL`F&kt4sn|T9&i)&Ov zOcWk{56qy&I~0t-BseKi{{byNOv)?-A*NIx(U$3F`fh#vKe_;}wFG*^@E~vrP0Cvv zu(nH}-4N+68<4NhdMR|4dTBJaggH|WgoWxwFAo+E!VA@Ry#lGETt0|UH-p#)FmDX> zB8a4WQCxK>6sVdA235yioTLDuLY30X<f*)EEC!wUw+O2Dlasz(x$)e&?)WcDUu?&- zfAFWp4WNG<j!`j2x+7eD_Vn!XA}?+5Z*0%xY;I1wvqh@0#^)XCPDG0&b>Fz7V<?d; zO^Q_c(Db=8fM&_u3Tzf+T!%)dVEAI$NHU?36v&qf6?s^=Ad%dO>=Qx5BC{U2DGItQ zm<UBxDGJb5NpdlZUlfs<M{lLU4jj#bDRT<+va+cx5+p`tyDCp16PAc^E15wnm!Ykp zd6sI>7;urk6O=Q{R3ReY?3x`jW}=nCw8tTkmm3pIqcfN+c5IyCEay1S1uk+4LLga4 z4w8oyAVnbP%wT!kDJ)MWjEFDegtQqGq;;2w`ZFm+{aG9$KF0`YvusG~s$lw>%FUgr z$@aQFnC9}#%Z5UqF}Zmgnx5hP-V4&YR!Ez@<&ppze*6pVb%^@Yw@4mV&iy#3@+BZ; zH0sQ@y8(hq7u*d4m2X*N8}UIlzAVCL8r44P6nv<>Vx8mC3{Uz2v~S8kg|Fr9<2zVL S*`!;CpE=t5KRzi04o(09?GiZv literal 4832 zcmV<65+Ch%Pew8T0RR91021H;4*&oF03+}K01}A+0RR9100000000000000000000 z0000SR0d!GgnS4N37iZO2nv{dl^F{v00A}vBm+<cAO(d@2Z22dfgBs!AhWV=kXAoJ z_Fo@xV+eLedBvzIT6C3Xd4>~VPdJ_!2WSTttvBuOt5t?NL)YmCJG~_qJ(8)J$XD0k zAj9=sLs%nncEeRTi$yi=m=Ti9^8CZSdT!<vjo^<bSn$ck(li*`toSs;|8LE{`-+;9 zbTtRLhoL?&8?kIN$M3RmgpQP0qgIcv?oFp-O-q(@BM+dC_4HBqO75=WErA83gw6@v zv;?RnH4&sD#vnk~I&}O!tIc{nCQ0kb9ILFe>Qw7*T1%LT*w=n#cfU6?OMFBsM2Y~^ z*2b|s{^x=!lE|2oF)EIH;t!rrTe=Pu6_5g7`Kj#D2FQRqO8D%M6qI}$hN9lCdRjZw zR~cI8L!l2uf;to*P-tuMu5E*V;+X#}5^!3g0hCoYK%W2KTwnIxN@{)$plUxz!k}d5 z(v?n5y-Geq`V*!Hc!5YLRR4@5$^xQV+ivmrwd>Zn6>_a#VdM^D7l4UtI6Z30w4#I< zC65(qHZt+QrGSwoIS|$iuivRkm-*}aswx6Npm}I6U%kkcUF{_Z1iMN_ecAT?)K<OF zy4x{My!3wi{0j?A&?4J{|GeGvK~w!zUORU2U-NM1L0?2*)uOsoTf70*m<KxFpF~t$ z#JPFA@NNZbCpoGXhw;f-`9DgXfZjEh@rE$mBK|)fo5STPC@S%32_%$Ake=XK6LZcO zTMJ5Ei7cfhOKHneI<l0mETtz)>B~|EvXr4LWh6@(%UWszrA%chGg-=9ma>qgEax1? z%4+t>(em9G|L4Afg6lAdBG>+6Y<~@P`5>^i%=kV%m-fyb%=156Y_BG|X{-hJfZFd6 zoJ!t7pq-yP7}TnDa7eSoXZBSmdqk9!d|@*^Uk2T$V7&8FC>V^r+#t?=cC4$5$<ui| zVqxrcrP-;IM+8RoT@<2T@(Rwvv)HA9EsR8pClUtYy{9w?{Gdh};jb|wKIq1w$iZZQ zs)B`k+;|usrcHZaxqh}~qwCljjBVXVDJaPeV;oF)cVfvdMWVl^$a86Dc_Nq7&bG<c z9f%FuXh|_Xdl8sZ-~?O*a9Cx#HU^7kY4b4u)$)pZ-oH{_#@OTtr5=E%_!W{;eRN33 zKoFiA!Qwbzna4J7tc_51C{=re(nzsdUIMhJvB|oAY5`d$$fmvc;FPxg`NLEyOT7a` zTn}k%@<t3AZ4}I9AlB$4$j0Ry7H~Mzq07W%e<;Vi3b2C=YdweBP{wLH90<^3N}T)^ z@=_8PF*yd;r+(pDE;?sSP7VCDwHoqnY(;T_!7EczK!hh5w7nvYqU*41j7i$d7-hA< z{Wk8We@}{WYw7<0Zni`vEzIlRDvk)>j)OI7<Fs}7D6FgE^_#?@AxlK?c{O%^7%?Bw z6S#m*909bikWBe2<W<`%1WeLC*X~`CW`?+SClt}?$CDJ_^yiMEzV&eJcD>eE=|n5W zD46116V?}piYG`B5az!4CFqMmb1HBjhoZVtg!^_Ti%fLW>X&d78yc~E_FGdUK1XPn zKe-7*-4gS?WPz-$4-xGAQV7j2EkX68uI-&bXkG@C%u_Uwy`(?og((CQ$w}{}r}*_j zrNjd{M>ESOq9aAYZ0eK=qOxFA5uB<*P)$gx3q=iKQB&B|5>8hR)%`_L`P!YJQbW&c z$`c)P0TrqHzU6$y(ET`NZB#$ZlZ<^(`Bm5GMzWy;MHh-56n!WLPz<3MK{1A60>u>C zy8v0VR$5Fa%2|54jC$rQQf9$jYnE_hk~}?4$1+4lvLuU|XLX}~%c<5#PAi34v6T8M zXS)>3%_76>Ae@d8#;dBi<@DY)$OX`y>!{G(RQ#SAN-#oF5p(~*$27}2Th>A%=PB8M zEXoLa3n2dkIW|!rSafWKOCV03zQ{HTlhmb_5wJC7=t?^qSscj{Hq93I4Vdq?0?qE7 zcI*JmeP^Opc=E$!mx7$c<IJ;m!9*6@EwGK<2B}#Mpv*^X6P|BRjx^_COX%*lsUvZB zHTtdWs7q;kk*BG{gyO1d)0VEVN4kJuY81)bsAB}PK{C~DGJ0!PdHDiShk4d0E{8C0 zK8J@*QnHFzPdW8zBRwM-wjp8$gfp+fR`dpZ2BKq64{M1CGPdaA>~Y0@Jub}*p<W<o zJ>$R^q@ZPQx6xIoWl^mO>sBCDiojast2<peZYdb>W*t3PQr7If>aeRdAGV$~(az5s zp?OCPRB8=+IXhz*n~fM`G1g@%#4|TrpV)W9jPL$rvRfSad7lD1X<<`OgWc4&wRW#Z z*lvYOp;)u@n$FZ?&Nbtx^|4IByx7`0jXv*}T4!0O&!V_J?%{22&;N>%18aHoHtgw( zI%}+}G};J}&UIlUX@t+OJl$xutR=aT__z?yuJUyq)^4-sOl>rG<l@jJ6of@Bn<Tjm zwi{i!8s;jMT83?}XsYfithtseQ|cPfJK-!tNRYy_5F!E_rQ@84X+-f^2nj(t+1(Ow ziX!EZ(z6jV4wPLa5ONMFKO3RoV2k{4MwD<yl#wPXIB2UyoEJ4+r1~s`h9GJ}!fDa+ zo7A3-&~b>mNF($dQhzqWz+rOOE!ttcXf#oaU$|og>_p*y0YNi8jG-%L5@#-<MYDD? z#M0e~mAf-u4-updfFIi+kaiN<H)|Iw9Nb+vy1Q_4ci|i$x=83M;Tx>jjk~)W4|g}7 z?ryvSL~jXwB)#9ccjr;<y9dhkZZg=xSH&6|-@6XBI^gbIvZKC8uR;MHuP`P)fM;1! zzAobfpzQzv`Z@q};EbEYbbx?7xJZB(W`jFx6vGHdYZxBs88%#P=MU~Xj-^cvOx(oX z-Q2*i=WhTPwP|2LIRla}Xb^wbGu|UnzGNAN2^R=1mnVK!xe@K-MFB`?1<=Z_k+b)? z2;uBS{vcDSwg%N2wT+F&CO~tb&%uSF1ei9I4@{6GU5H4LRRVdvav0S6WW=gLg^D$V z)uF)HS)9UnLammoLM_im|HtQ#qH{!mYz{5tFGBbdHFkio`d27CfcfwL&9_jATTdqN z7DzcU2RJ$l8PDcHolh2E7)7usH-17lX72$I=%42qL82G<eDx#+%L~M`MT8ck@I{oA zp|gt=rX}YZ0MiH&sn`@(S~5_TjTxb_*1^;W1D$1>;`1&#*aF9ln4@F&Z{EFd=f*w6 zi0;xS&W9;#F>nV0!Sv;n4?Hws2goH#+<KWBvk__tt7@r!Lx>8kLzULXxZ2uUt%m9D zY=7^KKX+u*5(3JDM2uSUKapVc01Q(BeH|Jb6GCNWrBVSj_-LQ}kZ<f+f59SzQ!Uy} zBrwabLP;5ZbSa3baU}qtDne6<j#@(^rP30@t(QXherrq%(`S==r+kcv33S|>Mv!uT zw<W?CC_VX*K<&*J2s1TgEdYfsQw=g=4UCM}Fv3^aAr-8%sYQ|$qO<rswg4074pl<i zsR9ZDj}S8E7al3pEm>|811Q%iY7qQHWA*Yfs$z8a!Ly^Dg9l0IJwh9E<^c8|8y9Vf zN=U|fwT(@TlJe<Xz<J)VKm`-R->7gE;X_d%_$P}3u@DmJ{r7ng+~0E+bALZUw?Aor zsB7!Vt%rmUPCj6TRkfxioJ~uTW=XTEC;v(b7mt5jsknI5-0WaM{>Gnw#(}z$77Qr8 z9+Wrv`Nv~rm2Cre%R7A(H5J>vMk2Oe_5;m07VeYGe_7I)&s7hFVd8B7FRP%XrF@Jd z>3rrr{UDBvJ$r@*K7Sn;*tKiVfNp3~-krYR>`d6k4!`5Fu+;ThdDZvp%h#I5-nzQp zMuj3RMPbJKK0Z!pxp29PZ9TbR**%Y|8+#l3`?E4~<IS%6PWWGS$;jyM8_Pb^^zoe8 zSKq5<7G}sco!D^NxPOno$hXgb+;_}Z<bTdLSh8E<o-Y_Mwsm|fL~rj;->(neV8zF$ z%C>Fua<c>^j@Rdev?Og{4JB_cAZ>{(D5x*UtIt#CD+raC23;)z<B|m6br;bu#^`(s zWyg*ei5wlBcCXXx(HqtqE9`XclKMS`X2V_)iR(NOdhdJmVj*S!dwKPNK5q0qE^Y&$ zlh^x%7tu>%+-3ujsA`ZKlluxR$~_w6Y;l}1(+J0~qmOOr-_h&bD~9(w?<*-D*(VI` zcRXKQjDFH~e?od+*4yZC%{>5z6v#|iP}6bpfa};XvdfV-E2EMO(nK<*<fc*6F>>7X z;Bm6a0Wp?Q$p-17ADKoy^|lPIxhH5t&zBh^$)Sn)bMmVR-zO%r{Fb2fjR7Z40aFvG zC@AaxK2b-<(RJ&NceH`c1L3+)`aPyS`k&L9abslmxucQpr-h(N)8F`}NrZ8lu!iI8 z)MIyXcePVrWZ$$iXY)D6M!xiE<*bfmx8~=!Gh2l0wt7zMfT)F?m(OgYc@=vxn%VWu zoLN>2n9|A;4X|78%A;jbua#Yp&uTB`wsIz~rB#-1tgP$;#noU(xj6MqiMJ#-HJ9&Y z*WJTdzD~ebru%0G3f8O^Gpm_V74cG9TW4*MG(g<nFAk6f)?xS;aYeDXgta!ZnbWCk zH@IBiZg5pU6Phu0G}ZZaL;1F><=2g%|4=#VpE9ry&f;!2nF<dx_1qBG@B^gxYH8YP z7mi4=Kd8tpBPs}yMK!Nw_1QKzKL0#4xEtfmxwcjp%#~-@DZ*DL8S$j4w20TdC`o94 z&)8!<u5Erz&EBH1DT^esG0jx}h6n)wd*1pMR_@#B)})(VO0^p+0a|9~Kd+D4X8)S8 zO@Sfn5sg`&43>46fG4)?4&;h4SIB$H#Q^(|bw~B(#xJ;Vln8hdSD5P0%~TK!u}SG3 ziPY7Wh<D|b;@L2Wg8VsYTaD(XQX`NjiiD#em;s*jyFI4*@}C3eW<E2qk7l+JL(E7I z8JG;3C17ytfw@J=qIB`S-Hr9hxZkZ+lfcSM?ulTfFCd@GBR4zuDwZ!WXZc)Lk|8FG z-6AD2U2aL@Z7i}+x7+o4xl9<JYWKRGdMoCdjdJaKs#ek@j>15foQOVVTBW&}SHz=& zC@!@L60ULrbQgloYM=1>SU=MlDy)RcKh4cX%*d()E9Fw&h2b_6@)Hmo@>wd;7_AEe zB6{!;{4(6bZ3GXe1HF(BdzDPLf>soTwi?LIDgcl09%3#9Gezq{q%Ld4l!NKep+TGk z;g1E58mUi<s&8UcE5=#K(`q^F67dd+f0Psx<Lr>4r|8o*35iNwH~{C%!(B;moIPpM z&Y;{$lX2v#vD{1oVipPM1PBwC(rkcmJ_u){EE)+l_)cwGABNxwm~4raivkmRjua(c z?G0161uZXkdVVfi4dU)<lJNNOCkFjqy;fD6tA>><)f_olwNj43BV;Gw$gQ{tQ6vd3 znok!OrYreMyH$}ET)!rHmk6^g2x)tkC77eYIx@S&V}djZ;;0u|ZvC$)WnKEgzkc5g z;U@D3KxC!U2rv&x3F5N2-FLcXMxh>!X3HP`fpJE0-W>Lo8qUqh$!O4E(!)R+X~8{D z%H^*M`DH=4R*dAq1h6G>Jqo(R6f{vkysPhLH+|;s{crpB7l2>J`KD)%!+||t;<tdC zWrfK8jks3$h8y~KQ|&gO#iCYUJm1K8Bmbg~aawHP|HL7Arf;BjgXW_vEzi_;dhIh` z+`<9)%LwB*f~SD{r2=qw13w|qwqUfB$YI-3Az>%mnLNM_<%y*MMO@$r<?W>ftU>pc zi)i8%4tgHIJ$yt<i5mT-LWN^&P|mYMxxX}^!z&!2EG=FNK@k6GFue2Ue=^Y4Ysq&Q z4{uNh{`GeC4Q)D)g?{NdM8Q|ig!Ql&_?vZ|sZUSe=<NfgUm|(@HITf<Du>kYQw0NG zog4A`z{sA>k@Bcyia=4*UQ5x~F!O9=4tEyW5nftlVHt{8p@2eUAMpmU#MwGw5wkH* zzmI1#Nqj-AvJgEb%cI=%BtJ&YK2k~3FB<6^M!-3;B1J5rT7cc^ix`iQ$0G?~<N<oB zqEofK$RV?~WBx4aYlH*SvNnjfOtuCLcqd$Zm2qe^Is+pUbIh~AB1<f@!YXSqi+L<! z87rj6Kt%d1L}WgPN;+>s5wmsT_P-@^B~&rK09A}9K_#94z!IJjmdFuc=tWBqi$11u z7#RHWA{NfuGVb|4k#E5gz9TP-Ah`64AC~}$XA#hK!V_;H(n$zRds`Qt{sZ7uU-VA~ z;PhKRo$D`PKKCa{BF1fBTV)+syvP(^b;{p9RTOWUUft$ue13?^^p}5z_!YJ<{g5>Z GNo@dwG#q~b diff --git a/src/sass/general.scss b/src/sass/general.scss index ce97564..e6247d6 100644 --- a/src/sass/general.scss +++ b/src/sass/general.scss @@ -23,6 +23,7 @@ font-weight: bold; width: 30px; height: 30px; + padding: 0px 5px 1px 8px; } input { diff --git a/src/sass/index.scss b/src/sass/index.scss index a19165a..3f4b123 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -50,7 +50,7 @@ body { background-color: var(--bg_color); color: var(--fg_color); - font-family: $font_0, $font_1, $font_2, $font_3; + font-family: $font_0, $font_1; font-size: 15px; line-height: 1.3; margin: 0; @@ -143,30 +143,50 @@ ul { } .verified-icon { - color: var(--icon_text); - border-radius: 50%; - flex-shrink: 0; - margin: 2px 0 3px 3px; - padding-top: 3px; - height: 11px; - width: 14px; - font-size: 8px; display: inline-block; - text-align: center; - vertical-align: middle; + width: 14px; + height: 14px; + margin-left: 2px; + + .verified-icon-circle { + position: absolute; + font-size: 15px; + } + + .verified-icon-check { + position: absolute; + font-size: 9px; + margin: 5px 3px; + } &.blue { - background-color: var(--verified_blue); + .verified-icon-circle { + color: var(--verified_blue); + } + + .verified-icon-check { + color: var(--icon_text); + } } &.business { - color: var(--bg_panel); - background-color: var(--verified_business); + .verified-icon-circle { + color: var(--verified_business); + } + + .verified-icon-check { + color: var(--bg_panel); + } } &.government { - color: var(--bg_panel); - background-color: var(--verified_government); + .verified-icon-circle { + color: var(--verified_government); + } + + .verified-icon-check { + color: var(--bg_panel); + } } } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index 4e150e0..86bfbe7 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -59,13 +59,9 @@ nav { justify-content: flex-end; } - &.right a { - padding-left: 4px; - - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &.right a:hover { + color: var(--accent_light); + text-decoration: unset; } } @@ -81,10 +77,11 @@ nav { } } -.icon-info:before { +.icon-info { margin: 0 -3px; } .icon-cog { font-size: 15px; + padding-left: 0 !important; } diff --git a/src/sass/search.scss b/src/sass/search.scss index 444f9bb..c2adaf7 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -13,6 +13,7 @@ button { margin: 0 2px 0 0; + padding: 0px 1px 1px 4px; height: 23px; display: flex; align-items: center; @@ -35,7 +36,7 @@ background-color: var(--bg_elements); color: var(--fg_color); border: 1px solid var(--accent_border); - padding: 1px 6px 2px 6px; + padding: 1px 1px 2px 4px; font-size: 14px; cursor: pointer; margin-bottom: 2px; @@ -56,20 +57,17 @@ font-weight: initial; text-align: left; - > div { - line-height: 1.7em; - } - .checkbox-container { display: inline; padding-right: unset; - margin-bottom: unset; + margin-bottom: 5px; margin-left: 23px; } .checkbox { right: unset; left: -22px; + line-height: 1.6em; } .checkbox-container .checkbox:after { diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 790b3da..ba77b14 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -2,7 +2,7 @@ @import "_mixins"; video { - max-height: 100%; + height: 100%; width: 100%; } @@ -13,6 +13,7 @@ video { .gallery-video.card-container { flex-direction: column; + width: 100%; } .video-container { @@ -20,9 +21,6 @@ video { min-width: 200px; max-height: 530px; margin: 0; - display: flex; - align-items: center; - justify-content: center; img { max-height: 100%; diff --git a/src/views/general.nim b/src/views/general.nim index 23681b5..2525841 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,8 +52,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=21") - link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=22") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index fcdf06f..377a443 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -26,7 +26,9 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = template verifiedIcon*(user: User): untyped {.dirty.} = if user.verifiedType != VerifiedType.none: let lower = ($user.verifiedType).toLowerAscii() - icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") + buildHtml(tdiv(class=(&"verified-icon {lower}"))): + icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account") + icon "ok", class="verified-icon-check", title=(&"Verified {lower} account") else: text "" From 71e65c84d767ebb1d788e138d853cf0567fc970c Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sat, 29 Nov 2025 04:34:04 +0100 Subject: [PATCH 168/309] Round video duration properly --- src/formatters.nim | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 3ad1da6..fc0e197 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen +import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math import std/[enumerate, re] import types, utils, query @@ -157,15 +157,13 @@ proc getShortTime*(tweet: Tweet): string = proc getDuration*(video: Video): string = let ms = video.durationMs - sec = int(ms / 1000) - min = int(sec / 60) - hour = int(min / 60) - if hour > 1: + sec = int(round(ms / 1000)) + min = floorDiv(sec, 60) + hour = floorDiv(min, 60) + if hour > 0: return &"{hour}:{min mod 60}:{sec mod 60:02}" - elif min > 1: - return &"{min mod 60}:{sec mod 60:02}" else: - return &"0:{sec mod 60:02}" + return &"{min mod 60}:{sec mod 60:02}" proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return From 064ec8808022abb071f93f0fc976a8aa123699dc Mon Sep 17 00:00:00 2001 From: Zed <zedeus@pm.me> Date: Sun, 30 Nov 2025 02:56:19 +0100 Subject: [PATCH 169/309] Transition to ID-only RSS GUIDs on Dec 14, 2025 Fixes #447 --- src/views/rss.nimf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 23744e5..717ad99 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -2,6 +2,9 @@ ## SPDX-License-Identifier: AGPL-3.0-only #import strutils, xmltree, strformat, options, unicode #import ../types, ../utils, ../formatters, ../prefs +## Snowflake ID cutoff for RSS GUID format transition +## Corresponds to approximately December 14, 2025 UTC +#const guidCutoff = 2000000000000000000'i64 # #proc getTitle(tweet: Tweet; retweet: string): string = #if tweet.pinned: result = "Pinned: " @@ -101,12 +104,17 @@ ${renderRssTweet(quoteTweet, cfg)} # if link in links: continue # end if # links.add link +# let useGlobalGuid = tweet.id >= guidCutoff <item> <title>${getTitle(tweet, retweet)} @${tweet.user.username} ${getRfc822Time(tweet)} +#if useGlobalGuid: + ${tweet.id} +#else: ${urlPrefix & link} +#end if ${urlPrefix & link} # end for From 4b9aec6fdebbadfaf91786e934e43679242f5c13 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 02:57:34 +0100 Subject: [PATCH 170/309] Use graphTweet for cookie sessions for now --- src/api.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api.nim b/src/api.nim index e97b4e0..acd25f1 100644 --- a/src/api.nim +++ b/src/api.nim @@ -42,7 +42,8 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = proc tweetDetailUrl(id: string; cursor: string): ApiReq = let cookieVars = tweetDetailVars % [id, cursor] result = ApiReq( - cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), + # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), + cookie: apiUrl(graphTweet, tweetVars % [id, cursor]), oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) ) From a62ec9cbb40066c2e05b5e81cb8aa61f921644d1 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 03:58:43 +0100 Subject: [PATCH 171/309] Normalize headers --- src/apiutils.nim | 14 ++++++-------- src/routes/media.nim | 8 ++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index b288141..b392ef7 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -48,21 +48,19 @@ proc getCookieHeader(authToken, ct0: string): string = proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result = newHttpHeaders({ + "accept": "*/*", + "accept-encoding": "gzip", + "accept-language": "en-US,en;q=0.9", "connection": "keep-alive", "content-type": "application/json", - "x-twitter-active-user": "yes", - "x-twitter-client-language": "en", "origin": "https://x.com", - "accept-encoding": "gzip", - "accept-language": "en-US,en;q=0.5", - "accept": "*/*", - "DNT": "1", - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "x-twitter-active-user": "yes", + "x-twitter-client-language": "en" }) case session.kind of SessionKind.oauth: - result["authority"] = "api.x.com" result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: result["x-twitter-auth-type"] = "OAuth2Session" diff --git a/src/routes/media.nim b/src/routes/media.nim index de51061..186b8d8 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -52,10 +52,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = "" let headers = newHttpHeaders({ - "Content-Type": res.headers["content-type", 0], - "Content-Length": contentLength, - "Cache-Control": maxAge, - "ETag": hashed + "content-type": res.headers["content-type", 0], + "content-length": contentLength, + "cache-control": maxAge, + "etag": hashed }) respond(request, headers) From 7734d976f7a6aee4d1abf16a14f0174500f04e7c Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 04:12:38 +0100 Subject: [PATCH 172/309] Add username validation Fixes #1317 --- src/routes/timeline.nim | 1 + tests/test_profile.py | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 49c7ce2..0894d83 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -114,6 +114,7 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) cond @"tab" in ["with_replies", "media", "search", ""] let prefs = cookiePrefs() diff --git a/tests/test_profile.py b/tests/test_profile.py index ea05add..cbf0256 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -15,7 +15,19 @@ protected = [ ['Poop', 'Randy', 'Social media fanatic.'] ] -invalid = [['thisprofiledoesntexist'], ['%']] +invalid = [['thisprofiledoesntexist']] + +malformed = [ + ['${userId}'], + ['$%7BuserId%7D'], # URL encoded version + ['%'], # Percent sign is invalid + ['user@name'], + ['user.name'], + ['user-name'], + ['user$name'], + ['user{name}'], + ['user name'], # space +] banner_image = [ ['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500'] @@ -65,6 +77,13 @@ class ProfileTest(BaseTestCase): self.open_nitter(username) self.assert_text(f'User "{username}" not found') + @parameterized.expand(malformed) + def test_malformed_username(self, username): + """Test that malformed usernames (with invalid characters) return 404""" + self.open_nitter(username) + # Malformed usernames should return 404 page not found, not try to fetch from Twitter + self.assert_text('Page not found') + def test_suspended(self): self.open_nitter('suspendme') self.assert_text('User "suspendme" has been suspended') From 693a1894625e0ba035554d23b3e9b94e99dab8a4 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 05:43:17 +0100 Subject: [PATCH 173/309] Add heuristics to detect when to show "Load more" Fixes #1328 --- src/views/status.nim | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/views/status.nim b/src/views/status.nim index 71c2c67..96af807 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = if thread.hasMore: renderMoreReplies(thread) -proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = +proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode = buildHtml(tdiv(class="replies", id="r")): + var hasReplies = false + var replyCount = 0 for thread in replies.content: if thread.content.len == 0: continue + hasReplies = true + replyCount += thread.content.len renderReplyThread(thread, prefs, path) - if replies.bottom.len > 0: - renderMore(Query(), replies.bottom, focus="#r") + if hasReplies and replies.bottom.len > 0: + if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies: + renderMore(Query(), replies.bottom, focus="#r") proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = let hasAfter = conv.after.content.len > 0 @@ -70,6 +75,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode if not conv.replies.beginning: renderNewer(Query(), getLink(conv.tweet), focus="#r") if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: - renderReplies(conv.replies, prefs, path) + renderReplies(conv.replies, prefs, path, conv.tweet) renderToTop(focus="#m") From e7413858286cc4c188aef14433dbd9e76256180c Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 18:06:22 +0100 Subject: [PATCH 174/309] Allow , in username to support multiple users Fixes #1329 --- src/routes/timeline.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 0894d83..4d8561b 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -114,7 +114,7 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] - cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ","}) cond @"tab" in ["with_replies", "media", "search", ""] let prefs = cookiePrefs() From 17fc2628f91f70b9bfda1915c76e94708a5197bf Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 30 Nov 2025 18:07:13 +0100 Subject: [PATCH 175/309] Minor fix --- src/routes/timeline.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 4d8561b..2ac87bb 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -114,7 +114,7 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] - cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ","}) + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) cond @"tab" in ["with_replies", "media", "search", ""] let prefs = cookiePrefs() From 663f5a52e13783075287e779b1b947b862259e7e Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 6 Dec 2025 05:00:34 +0100 Subject: [PATCH 176/309] Improve headers --- src/apiutils.nim | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index b392ef7..7a49e3e 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -56,7 +56,8 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = "origin": "https://x.com", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", "x-twitter-active-user": "yes", - "x-twitter-client-language": "en" + "x-twitter-client-language": "en", + "priority": "u=1, i" }) case session.kind @@ -66,6 +67,12 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) + result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24"""" + result["sec-ch-ua-mobile"] = "?0" + result["sec-ch-ua-platform"] = "Windows" + result["sec-fetch-dest"] = "empty" + result["sec-fetch-mode"] = "cors" + result["sec-fetch-site"] = "same-site" if disableTid: result["authorization"] = bearerToken2 else: From 51b54852dcf617c6131376a469c537ab6b8326e3 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 6 Dec 2025 05:15:01 +0100 Subject: [PATCH 177/309] Add preliminary support for nitter-proxy --- nitter.example.conf | 1 + src/apiutils.nim | 13 ++++++++++++- src/config.nim | 1 + src/nitter.nim | 1 + src/types.nim | 1 + 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/nitter.example.conf b/nitter.example.conf index dfdaf50..7e4c846 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -26,6 +26,7 @@ enableRSS = true # set this to false to disable RSS feeds enableDebug = false # enable request logs and debug endpoints (/.sessions) proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" +apiProxy = "" # nitter-proxy host, e.g. localhost:7000 disableTid = false # enable this if cookie-based auth is failing # Change default preferences here, see src/prefs_impl.nim for a complete list diff --git a/src/apiutils.nim b/src/apiutils.nim index 7a49e3e..ddb5027 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -13,10 +13,17 @@ const var pool: HttpPool disableTid: bool + apiProxy: string proc setDisableTid*(disable: bool) = disableTid = disable +proc setApiProxy*(url: string) = + if url.len > 0: + apiProxy = url.strip(chars={'/'}) & "/" + if "http" notin apiProxy: + apiProxy = "http://" & apiProxy + proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = case sessionKind of oauth: @@ -99,7 +106,11 @@ template fetchImpl(result, fetchBody) {.dirty.} = var resp: AsyncResponse pool.use(await genHeaders(session, url)): template getContent = - resp = await c.get($url) + # TODO: this is a temporary simple implementation + if apiProxy.len > 0: + resp = await c.get(($url).replace("https://", apiProxy)) + else: + resp = await c.get($url) result = await resp.body getContent() diff --git a/src/config.nim b/src/config.nim index 571508b..2b38d86 100644 --- a/src/config.nim +++ b/src/config.nim @@ -41,6 +41,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""), + apiProxy: cfg.get("Config", "apiProxy", ""), disableTid: cfg.get("Config", "disableTid", false) ) diff --git a/src/nitter.nim b/src/nitter.nim index e6d66ab..91a3a9f 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -37,6 +37,7 @@ setHmacKey(cfg.hmacKey) setProxyEncoding(cfg.base64Media) setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) +setApiProxy(cfg.apiProxy) setDisableTid(cfg.disableTid) initAboutPage(cfg.staticDir) diff --git a/src/types.nim b/src/types.nim index 815e223..c994148 100644 --- a/src/types.nim +++ b/src/types.nim @@ -275,6 +275,7 @@ type enableDebug*: bool proxy*: string proxyAuth*: string + apiProxy*: string disableTid*: bool rssCacheTime*: int From baeaf685d32098cd90ae1424cf8a19f3de9b0e2c Mon Sep 17 00:00:00 2001 From: jackyzy823 Date: Mon, 8 Dec 2025 17:05:08 +0800 Subject: [PATCH 178/309] Make maxConcurrentReqs configurable (#1341) --- nitter.example.conf | 1 + src/auth.nim | 11 +++++++---- src/config.nim | 3 ++- src/nitter.nim | 1 + src/types.nim | 1 + 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/nitter.example.conf b/nitter.example.conf index 7e4c846..4a6a026 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -28,6 +28,7 @@ proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" apiProxy = "" # nitter-proxy host, e.g. localhost:7000 disableTid = false # enable this if cookie-based auth is failing +maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/src/auth.nim b/src/auth.nim index 5d7ef0e..d801489 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -3,14 +3,17 @@ import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os import types, consts import experimental/parser/session -# max requests at a time per session to avoid race conditions -const - maxConcurrentReqs = 2 - hourInSeconds = 60 * 60 +const hourInSeconds = 60 * 60 var sessionPool: seq[Session] enableLogging = false + # max requests at a time per session to avoid race conditions + maxConcurrentReqs = 2 + +proc setMaxConcurrentReqs*(reqs: int) = + if reqs > 0: + maxConcurrentReqs = reqs template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") diff --git a/src/config.nim b/src/config.nim index 2b38d86..8cb334a 100644 --- a/src/config.nim +++ b/src/config.nim @@ -42,7 +42,8 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = proxy: cfg.get("Config", "proxy", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""), apiProxy: cfg.get("Config", "apiProxy", ""), - disableTid: cfg.get("Config", "disableTid", false) + disableTid: cfg.get("Config", "disableTid", false), + maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2) ) return (conf, cfg) diff --git a/src/nitter.nim b/src/nitter.nim index 91a3a9f..e2d6bec 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -39,6 +39,7 @@ setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) setApiProxy(cfg.apiProxy) setDisableTid(cfg.disableTid) +setMaxConcurrentReqs(cfg.maxConcurrentReqs) initAboutPage(cfg.staticDir) waitFor initRedisPool(cfg) diff --git a/src/types.nim b/src/types.nim index c994148..90487ab 100644 --- a/src/types.nim +++ b/src/types.nim @@ -277,6 +277,7 @@ type proxyAuth*: string apiProxy*: string disableTid*: bool + maxConcurrentReqs*: int rssCacheTime*: int listCacheTime*: int From a92e79ebc3581702dc427434a782a5fc1d28cc91 Mon Sep 17 00:00:00 2001 From: yav <150280490+796176@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:22:20 +0300 Subject: [PATCH 179/309] Fix the checkmark position (#1347) Co-authored-by: yav <796176@protonmail.com> --- src/views/profile.nim | 1 + src/views/renderutils.nim | 1 - src/views/timeline.nim | 1 + src/views/tweet.nim | 2 ++ 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/views/profile.nim b/src/views/profile.nim index 2b2e410..ee3f71d 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -26,6 +26,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = tdiv(class="profile-card-tabs-name"): linkUser(user, class="profile-card-fullname") + verifiedIcon(user) linkUser(user, class="profile-card-username") tdiv(class="profile-card-extra"): diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 377a443..a5fe3b2 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -42,7 +42,6 @@ proc linkUser*(user: User, class=""): VNode = buildHtml(a(href=href, class=class, title=nameText)): text nameText if isName: - verifiedIcon(user) if user.protected: text " " icon "lock", title="Protected account" diff --git a/src/views/timeline.nim b/src/views/timeline.nim index a205c04..fee45bc 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -66,6 +66,7 @@ proc renderUser(user: User; prefs: Prefs): VNode = tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): linkUser(user, class="fullname") + verifiedIcon(user) linkUser(user, class="username") tdiv(class="tweet-content media-body", dir="auto"): diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 58d03a9..d680509 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -31,6 +31,7 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): linkUser(tweet.user, class="fullname") + verifiedIcon(tweet.user) linkUser(tweet.user, class="username") span(class="tweet-date"): @@ -235,6 +236,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="fullname-and-username"): renderMiniAvatar(quote.user, prefs) linkUser(quote.user, class="fullname") + verifiedIcon(quote.user) linkUser(quote.user, class="username") span(class="tweet-date"): From a45227b8835719dfb443600052d69374db8b515c Mon Sep 17 00:00:00 2001 From: cmj <129799+cmj@users.noreply.github.com> Date: Thu, 29 Jan 2026 08:27:41 -0800 Subject: [PATCH 180/309] Add user-agent to guest_token request (#1359) --- tools/get_session.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/get_session.py b/tools/get_session.py index 9f91483..da03322 100644 --- a/tools/get_session.py +++ b/tools/get_session.py @@ -21,7 +21,10 @@ def auth(username, password, otp_secret): guest_token = requests.post( "https://api.twitter.com/1.1/guest/activate.json", - headers={'Authorization': bearer_token} + headers={ + 'Authorization': bearer_token, + "User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9" + } ).json().get('guest_token') if not guest_token: From 33dd9b66683e6838d6da16f5563c20de5d32ef5a Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 6 Feb 2026 20:32:44 +0100 Subject: [PATCH 181/309] Fix /pic/ exploit --- src/routes/media.nim | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/media.nim b/src/routes/media.nim index 186b8d8..011d0f3 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -93,6 +93,8 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/orig\/(enc)?\/?(.+)": var url = decoded(request, 1) + cond "amplify_video" notin url + if "twimg.com" notin url: url.insert(twimg) if not url.startsWith(https): @@ -107,6 +109,8 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/(enc)?\/?(.+)": var url = decoded(request, 1) + cond "amplify_video" notin url + if "twimg.com" notin url: url.insert(twimg) if not url.startsWith(https): From 0a6e79e6263b0c3aba2c0fec3b6af906d71a1622 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 02:55:07 +0100 Subject: [PATCH 182/309] Add bulk script create_sessions_browser.py --- tools/create_session_browser.py | 135 ++++++++++++------- tools/create_sessions_browser.py | 219 +++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 47 deletions(-) create mode 100644 tools/create_sessions_browser.py diff --git a/tools/create_session_browser.py b/tools/create_session_browser.py index 3a05cb1..eb3936b 100644 --- a/tools/create_session_browser.py +++ b/tools/create_session_browser.py @@ -20,73 +20,112 @@ Output: {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} """ -import sys -import json import asyncio -import pyotp -import nodriver as uc +import json import os +import sys + +import nodriver as uc +import pyotp async def login_and_get_cookies(username, password, totp_seed=None, headless=False): """Authenticate with X.com and extract session cookies""" # Note: headless mode may increase detection risk from bot-detection systems browser = await uc.start(headless=headless) - tab = await browser.get('https://x.com/i/flow/login') + tab = await browser.get("https://x.com/i/flow/login") try: # Enter username - print('[*] Entering username...', file=sys.stderr) - username_input = await tab.find('input[autocomplete="username"]', timeout=10) - await username_input.send_keys(username + '\n') - await asyncio.sleep(1) + print(f"[*] Entering username {username}...", file=sys.stderr) + + retry = 0 + while retry < 5: + username_input = await tab.find( + 'input[autocomplete="username"]', timeout=10 + ) + + pos = await username_input.get_position() + await tab.mouse_move(pos.x, pos.y, steps=50, flash=True) + await asyncio.sleep(0.1) + + await username_input.click() + await asyncio.sleep(0.5) + await username_input.send_keys(username) + await asyncio.sleep(0.2) + await username_input.send_keys("\n") + await asyncio.sleep(2) + + page_content = await tab.get_content() + if "Could not log you in" in page_content: + retry += 1 + wait = retry * 10 + print(f"Retrying in {wait} seconds...") + await asyncio.sleep(wait) + else: + break # Enter password - print('[*] Entering password...', file=sys.stderr) - password_input = await tab.find('input[autocomplete="current-password"]', timeout=15) - await password_input.send_keys(password + '\n') - await asyncio.sleep(2) + print("[*] Entering password...", file=sys.stderr) + pretry = 0 + while pretry < 5: + password_input = await tab.find( + 'input[autocomplete="current-password"]', timeout=15 + ) + await password_input.click() + await asyncio.sleep(0.5) + await password_input.send_keys(password) + await asyncio.sleep(0.2) + await password_input.send_keys("\n") + await asyncio.sleep(2) + + page_content = await tab.get_content() + if "Could not log you in" in page_content: + pretry += 1 + wait = pretry * 10 + print(f"Retrying in {wait} seconds...") + await asyncio.sleep(wait) + else: + break # Handle 2FA if needed page_content = await tab.get_content() - if 'verification code' in page_content or 'Enter code' in page_content: + if "verification code" in page_content or "Enter code" in page_content: if not totp_seed: - raise Exception('2FA required but no TOTP seed provided') + raise Exception("2FA required but no TOTP seed provided") - print('[*] 2FA detected, entering code...', file=sys.stderr) + print("[*] 2FA detected, entering code...", file=sys.stderr) totp_code = pyotp.TOTP(totp_seed).now() code_input = await tab.select('input[type="text"]') - await code_input.send_keys(totp_code + '\n') + await code_input.send_keys(totp_code + "\n") await asyncio.sleep(3) # Get cookies - print('[*] Retrieving cookies...', file=sys.stderr) + print("[*] Retrieving cookies...", file=sys.stderr) for _ in range(20): # 20 second timeout cookies = await browser.cookies.get_all() cookies_dict = {cookie.name: cookie.value for cookie in cookies} - if 'auth_token' in cookies_dict and 'ct0' in cookies_dict: - print('[*] Found both cookies', file=sys.stderr) - + if "auth_token" in cookies_dict and "ct0" in cookies_dict: # Extract ID from twid cookie (may be URL-encoded) user_id = None - if 'twid' in cookies_dict: - twid = cookies_dict['twid'] + if "twid" in cookies_dict: + twid = cookies_dict["twid"] # Try to extract the ID from twid (format: u%3D or u=) - if 'u%3D' in twid: - user_id = twid.split('u%3D')[1].split('&')[0].strip('"') - elif 'u=' in twid: - user_id = twid.split('u=')[1].split('&')[0].strip('"') + if "u%3D" in twid: + user_id = twid.split("u%3D")[1].split("&")[0].strip('"') + elif "u=" in twid: + user_id = twid.split("u=")[1].split("&")[0].strip('"') - cookies_dict['username'] = username + cookies_dict["username"] = username if user_id: - cookies_dict['id'] = user_id + cookies_dict["id"] = user_id return cookies_dict await asyncio.sleep(1) - raise Exception('Timeout waiting for cookies') + raise Exception("Timeout waiting for cookies") finally: browser.stop() @@ -94,7 +133,9 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal async def main(): if len(sys.argv) < 3: - print('Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]') + print( + "Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]" + ) sys.exit(1) username = sys.argv[1] @@ -107,49 +148,49 @@ async def main(): i = 3 while i < len(sys.argv): arg = sys.argv[i] - if arg == '--append': + if arg == "--append": if i + 1 < len(sys.argv): append_file = sys.argv[i + 1] i += 2 # Skip '--append' and filename else: - print('[!] Error: --append requires a filename', file=sys.stderr) + print("[!] Error: --append requires a filename", file=sys.stderr) sys.exit(1) - elif arg == '--headless': + elif arg == "--headless": headless = True i += 1 - elif not arg.startswith('--'): - if totp_seed is None: + elif not arg.startswith("--"): + if totp_seed is None: totp_seed = arg i += 1 else: # Unkown args - print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr) + print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr) i += 1 try: cookies = await login_and_get_cookies(username, password, totp_seed, headless) session = { - 'kind': 'cookie', - 'username': cookies['username'], - 'id': cookies.get('id'), - 'auth_token': cookies['auth_token'], - 'ct0': cookies['ct0'] + "kind": "cookie", + "username": cookies["username"], + "id": cookies.get("id"), + "auth_token": cookies["auth_token"], + "ct0": cookies["ct0"], } output = json.dumps(session) if append_file: - with open(append_file, 'a') as f: - f.write(output + '\n') - print(f'✓ Session appended to {append_file}', file=sys.stderr) + with open(append_file, "a") as f: + f.write(output + "\n") + print(f"✓ Session appended to {append_file}", file=sys.stderr) else: print(output) os._exit(0) except Exception as error: - print(f'[!] Error: {error}', file=sys.stderr) + print(f"[!] Error: {error}", file=sys.stderr) sys.exit(1) -if __name__ == '__main__': +if __name__ == "__main__": asyncio.run(main()) diff --git a/tools/create_sessions_browser.py b/tools/create_sessions_browser.py new file mode 100644 index 0000000..003eec3 --- /dev/null +++ b/tools/create_sessions_browser.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +Requirements: + pip install -r tools/requirements.txt + +Usage: + python3 tools/create_sessions_browser.py [--append sessions.jsonl] [--headless] [--delay] + +Examples: + # Output to terminal + python3 tools/create_sessions_browser.py + + # Append to sessions.jsonl + python3 tools/create_sessions_browser.py --append sessions.jsonl + + # Add 5 second delay between sessions (default: 1) + python3 tools/create_sessions_browser.py --delay 5 + + # Headless mode (may increase detection risk) + python3 tools/create_sessions_browser.py --headless + +Input (accounts_file): + [{"username": "user", "password": "pass", "totp": "totp_code"}, {...}, ...] + +Output: + {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} + {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} + ... +""" + +import asyncio +import json +import sys +from time import sleep + +import nodriver as uc +import pyotp + + +async def login_and_get_cookies(account, headless=False): + """Authenticate with X.com and extract session cookies""" + # Note: headless mode may increase detection risk from bot-detection systems + browser = await uc.start(headless=headless) + tab = await browser.get("https://x.com/i/flow/login") + + username = account["username"] + password = account["password"] + totp_seed = account["totp"] + + try: + # Enter username + print(f"[*] Entering username {username}...", file=sys.stderr) + + retry = 0 + while retry < 5: + username_input = await tab.find( + 'input[autocomplete="username"]', timeout=10 + ) + + pos = await username_input.get_position() + await tab.mouse_move(pos.x, pos.y, steps=50, flash=True) + await asyncio.sleep(0.1) + + await username_input.click() + await asyncio.sleep(0.5) + await username_input.send_keys(username) + await asyncio.sleep(0.2) + await username_input.send_keys("\n") + await asyncio.sleep(2) + + page_content = await tab.get_content() + if "Could not log you in" in page_content: + retry += 1 + wait = retry * 10 + print(f"Retrying in {wait} seconds...") + await asyncio.sleep(wait) + else: + break + + # Enter password + print("[*] Entering password...", file=sys.stderr) + pretry = 0 + while pretry < 5: + password_input = await tab.find( + 'input[autocomplete="current-password"]', timeout=15 + ) + await password_input.click() + await asyncio.sleep(0.5) + await password_input.send_keys(password) + await asyncio.sleep(0.2) + await password_input.send_keys("\n") + await asyncio.sleep(2) + + page_content = await tab.get_content() + if "Could not log you in" in page_content: + pretry += 1 + wait = pretry * 10 + print(f"Retrying in {wait} seconds...") + await asyncio.sleep(wait) + else: + break + + # Handle 2FA if needed + page_content = await tab.get_content() + if "verification code" in page_content or "Enter code" in page_content: + if not totp_seed: + raise Exception("2FA required but no TOTP seed provided") + + print("[*] 2FA detected, entering code...", file=sys.stderr) + totp_code = pyotp.TOTP(totp_seed).now() + code_input = await tab.select('input[type="text"]') + await code_input.send_keys(totp_code + "\n") + await asyncio.sleep(3) + + # Get cookies + print("[*] Retrieving cookies...", file=sys.stderr) + for _ in range(20): # 20 second timeout + cookies = await browser.cookies.get_all() + cookies_dict = {cookie.name: cookie.value for cookie in cookies} + + if "auth_token" in cookies_dict and "ct0" in cookies_dict: + # Extract ID from twid cookie (may be URL-encoded) + user_id = None + if "twid" in cookies_dict: + twid = cookies_dict["twid"] + # Try to extract the ID from twid (format: u%3D or u=) + if "u%3D" in twid: + user_id = twid.split("u%3D")[1].split("&")[0].strip('"') + elif "u=" in twid: + user_id = twid.split("u=")[1].split("&")[0].strip('"') + + cookies_dict["username"] = username + if user_id: + cookies_dict["id"] = user_id + + return cookies_dict + + await asyncio.sleep(1) + + raise Exception("Timeout waiting for cookies") + + finally: + browser.stop() + + +async def main(): + if len(sys.argv) < 2: + print( + "Usage: python3 create_sessions_browser.py [--append sessions.jsonl] [--headless]" + ) + sys.exit(1) + + input = sys.argv[1] + append_file = None + headless = False + delay = 1 + + # Parse optional arguments + i = 2 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == "--append": + if i + 1 < len(sys.argv): + append_file = sys.argv[i + 1] + i += 2 # Skip '--append' and filename + else: + print("[!] Error: --append requires a filename", file=sys.stderr) + sys.exit(1) + elif arg == "--headless": + headless = True + i += 1 + elif arg == "--delay": + delay = int(sys.argv[i + 1]) + i += 2 + else: + # Unkown args + print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr) + i += 1 + + accounts = [] + with open(input) as f: + accounts = json.load(f) + + if len(accounts) == 0: + print("no accounts in file") + sys.exit(0) + + sessions = 0 + for acc in accounts: + sessions += 1 + try: + cookies = await login_and_get_cookies(acc, headless) + session = { + "kind": "cookie", + "username": cookies["username"], + "id": cookies.get("id"), + "auth_token": cookies["auth_token"], + "ct0": cookies["ct0"], + } + + if append_file: + with open(append_file, "a") as f: + f.write(json.dumps(session) + "\n") + else: + print(json.dumps(session)) + + print(f"Progress: {sessions} / {len(accounts)}") + if sessions < len(accounts): + print("Waiting", delay, "seconds") + sleep(delay) + except Exception as error: + print( + f"[!] Error getting session for {acc["username"]}, skipping: {error}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + asyncio.run(main()) From 5d28bd18c631417129a6c6a1ed45e20ef4528269 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 17:32:03 +0100 Subject: [PATCH 183/309] Add preference for configuring sticky navbar Fixes #1354 --- src/nitter.nim | 4 ++-- src/prefs_impl.nim | 3 +++ src/routes/router_utils.nim | 7 +------ src/routes/unsupported.nim | 2 +- src/sass/index.scss | 5 ++++- src/sass/navbar.scss | 5 ++++- src/sass/profile/_base.scss | 6 +++++- src/sass/tweet/thread.scss | 6 ++++-- src/views/general.nim | 3 ++- 9 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index e2d6bec..9e20ecb 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -66,10 +66,10 @@ settings: routes: get "/": - resp renderMain(renderSearch(), request, cfg, themePrefs()) + resp renderMain(renderSearch(), request, cfg, cookiePrefs()) get "/about": - resp renderMain(renderAbout(), request, cfg, themePrefs()) + resp renderMain(renderAbout(), request, cfg, cookiePrefs()) get "/explore": redirect("/about") diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 8e2ac8f..e55c2b8 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -60,6 +60,9 @@ genPrefs: stickyProfile(checkbox, true): "Make profile sidebar stick to top" + stickyNav(checkbox, true): + "Keep navbar fixed to top" + bidiSupport(checkbox, false): "Support bidirectional text (makes clicking on tweets harder)" diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index a071a0d..34fd163 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -17,13 +17,8 @@ template cookiePrefs*(): untyped {.dirty.} = template cookiePref*(pref): untyped {.dirty.} = getPref(cookies(request), pref) -template themePrefs*(): Prefs = - var res = defaultPrefs - res.theme = cookiePref(theme) - res - template showError*(error: string; cfg: Config): string = - renderMain(renderError(error), request, cfg, themePrefs(), "Error") + renderMain(renderError(error), request, cfg, cookiePrefs(), "Error") template getPath*(): untyped {.dirty.} = $(parseUri(request.path) ? filterParams(request.params)) diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 362b36b..e06a183 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -10,7 +10,7 @@ export feature proc createUnsupportedRouter*(cfg: Config) = router unsupported: template feature {.dirty.} = - resp renderMain(renderFeature(), request, cfg, themePrefs()) + resp renderMain(renderFeature(), request, cfg, cookiePrefs()) get "/about/feature": feature() get "/login/?@i?": feature() diff --git a/src/sass/index.scss b/src/sass/index.scss index 3f4b123..c12b814 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -115,11 +115,14 @@ ul { display: flex; flex-wrap: wrap; box-sizing: border-box; - padding-top: 50px; margin: auto; min-height: 100vh; } +body.fixed-nav .container { + padding-top: 50px; +} + .icon-container { display: inline; } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index 86bfbe7..c999022 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -3,7 +3,6 @@ nav { display: flex; align-items: center; - position: fixed; background-color: var(--bg_overlays); box-shadow: 0 0 4px $shadow; padding: 0; @@ -16,6 +15,10 @@ nav { .icon-button button { color: var(--fg_nav); } + + body.fixed-nav & { + position: fixed; + } } .inner-nav { diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index b7f33e6..3abc736 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -39,7 +39,11 @@ text-align: left; vertical-align: top; max-width: 32%; - top: 50px; + top: 0; + + body.fixed-nav & { + top: 50px; + } } .profile-result { diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 9d2fb64..d9bc457 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -16,8 +16,10 @@ .main-tweet, .replies { - padding-top: 50px; - margin-top: -50px; + body.fixed-nav & { + padding-top: 50px; + margin-top: -50px; + } } .main-tweet .tweet-content { diff --git a/src/views/general.nim b/src/views/general.nim index 2525841..3bae9d3 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -131,7 +131,8 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, rss, twitterLink) - body: + let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" + body(class=bodyClass): renderNavbar(cfg, req, rss, twitterLink) tdiv(class="container"): From db36f75519b2295c184d107f29210141bdd01082 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 20:23:31 +0100 Subject: [PATCH 184/309] Support restoring preferences via new prefs param Fixes #1352 Fixes #553 Fixes #249 --- src/nitter.nim | 5 +++++ src/prefs.nim | 9 +++++++-- src/prefs_impl.nim | 30 ++++++++++++++++++++++++++++++ src/routes/preferences.nim | 4 +++- src/routes/router_utils.nim | 32 +++++++++++++++++++++++++++++--- src/sass/index.scss | 18 ++++++++++++------ src/sass/inputs.scss | 12 ++++++++++++ src/utils.nim | 2 +- src/views/preferences.nim | 9 ++++++++- 9 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index 9e20ecb..442a8c0 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -65,6 +65,11 @@ settings: reusePort = true routes: + before: + # skip all file URLs + cond "." notin request.path + applyUrlPrefs() + get "/": resp renderMain(renderSearch(), request, cfg, cookiePrefs()) diff --git a/src/prefs.nim b/src/prefs.nim index fa40a6d..573ccae 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,10 +1,10 @@ # SPDX-License-Identifier: AGPL-3.0-only -import tables +import tables, strutils, base64 import types, prefs_impl from config import get from parsecfg import nil -export genUpdatePrefs, genResetPrefs +export genUpdatePrefs, genResetPrefs, genApplyPrefs var defaultPrefs*: Prefs @@ -20,3 +20,8 @@ template getPref*(cookies: Table[string, string], pref): untyped = var res = defaultPrefs.`pref` genCookiePref(cookies, pref, res) res + +proc encodePrefs*(prefs: Prefs): string = + var encPairs: seq[string] + genEncodePrefs(prefs) + encode(encPairs.join("&"), safe=true) diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index e55c2b8..2faf8ef 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -205,6 +205,36 @@ macro genResetPrefs*(): untyped = result.add quote do: savePref(`name`, "", `req`, expire=true) +macro genEncodePrefs*(prefs): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let + name = newLit(pref.name) + ident = ident(pref.name) + kind = newLit(pref.kind) + defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name)) + + result.add quote do: + when `kind` == checkbox: + if `prefs`.`ident` != `defaultIdent`: + if `prefs`.`ident`: + encPairs.add `name` & "=on" + else: + encPairs.add `name` & "=" + else: + if `prefs`.`ident` != `defaultIdent`: + encPairs.add `name` & "=" & `prefs`.`ident` + +macro genApplyPrefs*(params, req): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let name = newLit(pref.name) + result.add quote do: + if `name` in `params`: + savePref(`name`, `params`[`name`], `req`) + else: + savePref(`name`, "", `req`, expire=true) + macro genPrefsType*(): untyped = let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) result = quote do: diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim index b8af03d..345ff34 100644 --- a/src/routes/preferences.nim +++ b/src/routes/preferences.nim @@ -20,7 +20,9 @@ proc createPrefRouter*(cfg: Config) = get "/settings": let prefs = cookiePrefs() - html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir)) + prefsCode = encodePrefs(prefs) + prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode + html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl) resp renderMain(html, request, cfg, prefs, "Preferences") get "/settings/@i?": diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index 34fd163..2ef248a 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -1,15 +1,15 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, sequtils, uri, tables, json +import strutils, sequtils, uri, tables, json, base64 from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri +export utils, prefs, types, uri, base64 template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): setCookie(pref, value, daysForward(when expire: -10 else: 360), - httpOnly=true, secure=cfg.useHttps, sameSite=None) + httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/") template cookiePrefs*(): untyped {.dirty.} = getPrefs(cookies(request)) @@ -38,5 +38,31 @@ template getCursor*(req: Request): string = proc getNames*(name: string): seq[string] = name.strip(chars={'/'}).split(",").filterIt(it.len > 0) +template applyUrlPrefs*() {.dirty.} = + if @"prefs".len > 0: + try: + let decoded = decode(@"prefs") + var params = initTable[string, string]() + for pair in decoded.split('&'): + let kv = pair.split('=', maxsplit=1) + if kv.len == 2: + params[kv[0]] = kv[1] + elif kv.len == 1 and kv[0].len > 0: + params[kv[0]] = "" + genApplyPrefs(params, request) + except: discard + + # Rebuild URL without prefs param + var params: seq[(string, string)] + for k, v in request.params: + if k != "prefs": + params.add (k, v) + + if params.len > 0: + let cleanUrl = request.getNativeReq.url ? params + redirect($cleanUrl) + else: + redirect(request.path) + template respJson*(node: JsonNode) = resp $node, "application/json" diff --git a/src/sass/index.scss b/src/sass/index.scss index c12b814..4ca4f3d 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -99,12 +99,18 @@ legend { margin-bottom: 8px; } -.preferences .note { - border-top: 1px solid var(--border_grey); - border-bottom: 1px solid var(--border_grey); - padding: 6px 0 8px 0; - margin-bottom: 8px; - margin-top: 16px; +.preferences { + .note { + border-top: 1px solid var(--border_grey); + border-bottom: 1px solid var(--border_grey); + padding: 6px 0 8px 0; + margin-bottom: 8px; + margin-top: 16px; + } + + .bookmark-note { + margin: 0; + } } ul { diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index aafa5b8..7ea2b0a 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -200,4 +200,16 @@ input::-webkit-datetime-edit-year-field:focus { .pref-reset { float: left; } + + .prefs-code { + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); + color: var(--fg_color); + font-size: 12px; + padding: 6px 8px; + margin: 4px 0; + word-break: break-all; + white-space: pre-wrap; + user-select: all; + } } diff --git a/src/utils.nim b/src/utils.nim index c96a6dd..667299c 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -9,7 +9,7 @@ var const https* = "https://" twimg* = "pbs.twimg.com/" - nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] + nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"] twitterDomains = @[ "twitter.com", "pic.twitter.com", diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 1787704..40e9e11 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -32,7 +32,8 @@ macro renderPrefs*(): untyped = result[2].add stmt -proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode = +proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; + prefsUrl: string): VNode = buildHtml(tdiv(class="overlay-panel")): fieldset(class="preferences"): form(`method`="post", action="/saveprefs", autocomplete="off"): @@ -40,6 +41,12 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode renderPrefs() + legend: text "Bookmark" + p(class="bookmark-note"): + text "Save this URL to restore your preferences (?prefs works on all pages)" + pre(class="prefs-code"): + text prefsUrl + h4(class="note"): text "Preferences are stored client-side using cookies without any personal information." From b85e8c5d7d7af91a2d86a497265142b6f6a4f490 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 21:54:57 +0100 Subject: [PATCH 185/309] Support preference overrides using URL params Fixes #186 --- src/nitter.nim | 6 +++--- src/prefs.nim | 15 +++++---------- src/prefs_impl.nim | 34 +++++++--------------------------- src/routes/embed.nim | 2 +- src/routes/list.nim | 4 ++-- src/routes/media.nim | 2 +- src/routes/preferences.nim | 2 +- src/routes/resolver.nim | 4 ++-- src/routes/router_utils.nim | 32 +++++++++++++------------------- src/routes/rss.nim | 19 ++++++++++++------- src/routes/search.nim | 2 +- src/routes/status.nim | 2 +- src/routes/timeline.nim | 2 +- src/routes/unsupported.nim | 2 +- src/sass/index.scss | 1 + src/sass/inputs.scss | 2 +- src/views/general.nim | 4 +--- src/views/preferences.nim | 2 ++ src/views/renderutils.nim | 6 +++--- src/views/rss.nimf | 22 +++++++++++----------- 20 files changed, 70 insertions(+), 95 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index 442a8c0..ec2decf 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -71,10 +71,10 @@ routes: applyUrlPrefs() get "/": - resp renderMain(renderSearch(), request, cfg, cookiePrefs()) + resp renderMain(renderSearch(), request, cfg, requestPrefs()) get "/about": - resp renderMain(renderAbout(), request, cfg, cookiePrefs()) + resp renderMain(renderAbout(), request, cfg, requestPrefs()) get "/explore": redirect("/about") @@ -85,7 +85,7 @@ routes: get "/i/redirect": let url = decodeUrl(@"url") if url.len == 0: resp Http404 - redirect(replaceUrls(url, cookiePrefs())) + redirect(replaceUrls(url, requestPrefs())) error Http404: resp Http404, showError("Page not found", cfg) diff --git a/src/prefs.nim b/src/prefs.nim index 573ccae..1a75f75 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import tables, strutils, base64 +import tables, strutils import types, prefs_impl from config import get from parsecfg import nil @@ -11,17 +11,12 @@ var defaultPrefs*: Prefs proc updateDefaultPrefs*(cfg: parsecfg.Config) = genDefaultPrefs() -proc getPrefs*(cookies: Table[string, string]): Prefs = +proc getPrefs*(cookies, params: Table[string, string]): Prefs = result = defaultPrefs - genCookiePrefs(cookies) - -template getPref*(cookies: Table[string, string], pref): untyped = - bind genCookiePref - var res = defaultPrefs.`pref` - genCookiePref(cookies, pref, res) - res + genParsePrefs(cookies) + genParsePrefs(params) proc encodePrefs*(prefs: Prefs): string = var encPairs: seq[string] genEncodePrefs(prefs) - encode(encPairs.join("&"), safe=true) + encPairs.join(",") diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 2faf8ef..149eadf 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -130,7 +130,7 @@ macro genDefaultPrefs*(): untyped = result.add quote do: defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`) -macro genCookiePrefs*(cookies): untyped = +macro genParsePrefs*(prefs): untyped = result = nnkStmtList.newTree() for pref in allPrefs(): let @@ -140,37 +140,17 @@ macro genCookiePrefs*(cookies): untyped = options = pref.options result.add quote do: - if `name` in `cookies`: + if `name` in `prefs`: when `kind` == input or `name` == "theme": - result.`ident` = `cookies`[`name`] + result.`ident` = `prefs`[`name`] elif `kind` == checkbox: - result.`ident` = `cookies`[`name`] == "on" + result.`ident` = `prefs`[`name`] == "on" or + `prefs`[`name`] == "true" or + `prefs`[`name`] == "1" else: - let value = `cookies`[`name`] + let value = `prefs`[`name`] if value in `options`: result.`ident` = value -macro genCookiePref*(cookies, prefName, res): untyped = - result = nnkStmtList.newTree() - for pref in allPrefs(): - let ident = ident(pref.name) - if ident != prefName: - continue - - let - name = pref.name - kind = newLit(pref.kind) - options = pref.options - - result.add quote do: - if `name` in `cookies`: - when `kind` == input or `name` == "theme": - `res` = `cookies`[`name`] - elif `kind` == checkbox: - `res` = `cookies`[`name`] == "on" - else: - let value = `cookies`[`name`] - if value in `options`: `res` = value - macro genUpdatePrefs*(): untyped = result = nnkStmtList.newTree() let req = ident("request") diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 994364b..0527d3d 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -19,7 +19,7 @@ proc createEmbedRouter*(cfg: Config) = get "/@user/status/@id/embed": let tweet = await getGraphTweetResult(@"id") - prefs = cookiePrefs() + prefs = requestPrefs() path = getPath() if tweet == nil: diff --git a/src/routes/list.nim b/src/routes/list.nim index ac3e97e..7dadc22 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/?": cond '.' notin @"id" let - prefs = cookiePrefs() + prefs = requestPrefs() list = await getCachedList(id=(@"id")) timeline = await getGraphListTweets(list.id, getCursor()) vnode = renderTimelineTweets(timeline, prefs, request.path) @@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/members": cond '.' notin @"id" let - prefs = cookiePrefs() + prefs = requestPrefs() list = await getCachedList(id=(@"id")) members = await getGraphListMembers(list, getCursor()) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) diff --git a/src/routes/media.nim b/src/routes/media.nim index 011d0f3..b3e5374 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -143,6 +143,6 @@ proc createMediaRouter*(cfg: Config) = if ".m3u8" in url: let vid = await safeFetch(url) - content = proxifyVideo(vid, cookiePref(proxyVideos)) + content = proxifyVideo(vid, requestPrefs().proxyVideos) resp content, m3u8Mime diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim index 345ff34..5886c0e 100644 --- a/src/routes/preferences.nim +++ b/src/routes/preferences.nim @@ -19,7 +19,7 @@ proc createPrefRouter*(cfg: Config) = router preferences: get "/settings": let - prefs = cookiePrefs() + prefs = requestPrefs() prefsCode = encodePrefs(prefs) prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl) diff --git a/src/routes/resolver.nim b/src/routes/resolver.nim index 1baf873..5f074a5 100644 --- a/src/routes/resolver.nim +++ b/src/routes/resolver.nim @@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) = router resolver: get "/cards/@card/@id": let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] - respResolved(await resolve(url, cookiePrefs()), "card") + respResolved(await resolve(url, requestPrefs()), "card") get "/t.co/@url": let url = "https://t.co/" & @"url" - respResolved(await resolve(url, cookiePrefs()), "t.co") + respResolved(await resolve(url, requestPrefs()), "t.co") diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index 2ef248a..379280c 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -1,24 +1,21 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, sequtils, uri, tables, json, base64 +import strutils, sequtils, uri, tables, json from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri, base64 +export utils, prefs, types, uri template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): setCookie(pref, value, daysForward(when expire: -10 else: 360), httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/") -template cookiePrefs*(): untyped {.dirty.} = - getPrefs(cookies(request)) - -template cookiePref*(pref): untyped {.dirty.} = - getPref(cookies(request), pref) +template requestPrefs*(): untyped {.dirty.} = + getPrefs(cookies(request), params(request)) template showError*(error: string; cfg: Config): string = - renderMain(renderError(error), request, cfg, cookiePrefs(), "Error") + renderMain(renderError(error), request, cfg, requestPrefs(), "Error") template getPath*(): untyped {.dirty.} = $(parseUri(request.path) ? filterParams(request.params)) @@ -40,17 +37,14 @@ proc getNames*(name: string): seq[string] = template applyUrlPrefs*() {.dirty.} = if @"prefs".len > 0: - try: - let decoded = decode(@"prefs") - var params = initTable[string, string]() - for pair in decoded.split('&'): - let kv = pair.split('=', maxsplit=1) - if kv.len == 2: - params[kv[0]] = kv[1] - elif kv.len == 1 and kv[0].len > 0: - params[kv[0]] = "" - genApplyPrefs(params, request) - except: discard + var prefParams = initTable[string, string]() + for pair in @"prefs".split(','): + let kv = pair.split('=', maxsplit=1) + if kv.len == 2: + prefParams[kv[0]] = kv[1] + elif kv.len == 1 and kv[0].len > 0: + prefParams[kv[0]] = "" + genApplyPrefs(prefParams, request) # Rebuild URL without prefs param var params: seq[(string, string)] diff --git a/src/routes/rss.nim b/src/routes/rss.nim index b0e781d..6902001 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string = if cursor.len > 0: result &= ":" & cursor -proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} = +proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} = var profile: Profile let name = req.params.getOrDefault("name") @@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. return Rss(feed: profile.user.username, cursor: "suspended") if profile.user.fullname.len > 0: - let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1)) + let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1)) return Rss(feed: rss, cursor: profile.tweets.bottom) template respRss*(rss, page) = @@ -64,7 +64,9 @@ proc createRssRouter*(cfg: Config) = if @"q".len > 200: resp Http400, showError("Search input too long.", cfg) - let query = initQuery(params(request)) + let + prefs = requestPrefs() + query = initQuery(params(request)) if query.kind != tweets: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) @@ -78,7 +80,7 @@ proc createRssRouter*(cfg: Config) = let tweets = await getGraphTweetSearch(query, cursor) rss.cursor = tweets.bottom - rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) + rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs) await cacheRss(key, rss) respRss(rss, "Search") @@ -87,6 +89,7 @@ proc createRssRouter*(cfg: Config) = cond cfg.enableRss cond '.' notin @"name" let + prefs = requestPrefs() name = @"name" key = redisKey("twitter", name, getCursor()) @@ -94,7 +97,7 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, Query(fromUser: @[name])) + rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs) await cacheRss(key, rss) respRss(rss, "User") @@ -104,6 +107,7 @@ proc createRssRouter*(cfg: Config) = cond '.' notin @"name" cond @"tab" in ["with_replies", "media", "search"] let + prefs = requestPrefs() name = @"name" tab = @"tab" query = @@ -122,7 +126,7 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, query) + rss = await timelineRss(request, cfg, query, prefs) await cacheRss(key, rss) respRss(rss, "User") @@ -147,6 +151,7 @@ proc createRssRouter*(cfg: Config) = get "/i/lists/@id/rss": cond cfg.enableRss let + prefs = requestPrefs() id = @"id" cursor = getCursor() key = redisKey("lists", id, cursor) @@ -159,7 +164,7 @@ proc createRssRouter*(cfg: Config) = list = await getCachedList(id=id) timeline = await getGraphListTweets(list.id, cursor) rss.cursor = timeline.bottom - rss.feed = renderListRss(timeline.content, list, cfg) + rss.feed = renderListRss(timeline.content, list, cfg, prefs) await cacheRss(key, rss) respRss(rss, "List") diff --git a/src/routes/search.nim b/src/routes/search.nim index e9f991d..4220427 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -19,7 +19,7 @@ proc createSearchRouter*(cfg: Config) = resp Http400, showError("Search input too long.", cfg) let - prefs = cookiePrefs() + prefs = requestPrefs() query = initQuery(params(request)) title = "Search" & (if q.len > 0: " (" & q & ")" else: "") diff --git a/src/routes/status.nim b/src/routes/status.nim index 0168dac..838b327 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -21,7 +21,7 @@ proc createStatusRouter*(cfg: Config) = if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) - let prefs = cookiePrefs() + let prefs = requestPrefs() # used for the infinite scroll feature if @"scroll".len > 0: diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 2ac87bb..d6d8c21 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -117,7 +117,7 @@ proc createTimelineRouter*(cfg: Config) = cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) cond @"tab" in ["with_replies", "media", "search", ""] let - prefs = cookiePrefs() + prefs = requestPrefs() after = getCursor() names = getNames(@"name") diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index e06a183..345dee7 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -10,7 +10,7 @@ export feature proc createUnsupportedRouter*(cfg: Config) = router unsupported: template feature {.dirty.} = - resp renderMain(renderFeature(), request, cfg, cookiePrefs()) + resp renderMain(renderFeature(), request, cfg, requestPrefs()) get "/about/feature": feature() get "/login/?@i?": feature() diff --git a/src/sass/index.scss b/src/sass/index.scss index 4ca4f3d..a85002e 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -110,6 +110,7 @@ legend { .bookmark-note { margin: 0; + margin-bottom: 10px; } } diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index 7ea2b0a..c69711a 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -205,7 +205,7 @@ input::-webkit-datetime-edit-year-field:focus { background-color: var(--bg_elements); border: 1px solid var(--accent_border); color: var(--fg_color); - font-size: 12px; + font-size: 13px; padding: 6px 8px; margin: 4px 0; word-break: break-all; diff --git a/src/views/general.nim b/src/views/general.nim index 3bae9d3..74ddcb4 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -39,9 +39,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; rss=""; alternate=""): VNode = - var theme = prefs.theme.toTheme - if "theme" in req.params: - theme = req.params["theme"].toTheme + let theme = prefs.theme.toTheme let ogType = if video.len > 0: "video" diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 40e9e11..b051a01 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -46,6 +46,8 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; text "Save this URL to restore your preferences (?prefs works on all pages)" pre(class="prefs-code"): text prefsUrl + p(class="bookmark-note"): + verbatim "You can override preferences with query parameters (e.g. ?hlsPlayback=on). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name." h4(class="note"): text "Preferences are stored client-side using cookies without any personal information." diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index a5fe3b2..6753c5a 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -65,20 +65,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod text text proc genCheckbox*(pref, label: string; state: bool): VNode = - buildHtml(label(class="pref-group checkbox-container")): + buildHtml(label(class="pref-group checkbox-container", title=pref)): text label input(name=pref, `type`="checkbox", checked=state) span(class="checkbox") proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = let p = placeholder - buildHtml(tdiv(class=("pref-group pref-input " & class))): + buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)): if label.len > 0: label(`for`=pref): text label input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) proc genSelect*(pref, label, state: string; options: seq[string]): VNode = - buildHtml(tdiv(class="pref-group pref-input")): + buildHtml(tdiv(class="pref-group pref-input", title=pref)): label(`for`=pref): text label select(name=pref): for opt in options: diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 717ad99..46d7eaf 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -49,10 +49,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} #end if #end proc # -#proc renderRssTweet(tweet: Tweet; cfg: Config): string = +#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string = #let tweet = tweet.retweet.get(tweet) #let urlPrefix = getUrlPrefix(cfg) -#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) +#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)

${text.replace("\n", "
\n")}

#if tweet.photos.len > 0: # for photo in tweet.photos: @@ -81,7 +81,7 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
${quoteTweet.user.fullname} (@${quoteTweet.user.username})

-${renderRssTweet(quoteTweet, cfg)} +${renderRssTweet(quoteTweet, cfg, prefs)}

${quoteLink} @@ -90,7 +90,7 @@ ${renderRssTweet(quoteTweet, cfg)} #end if #end proc # -#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = +#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string = #let urlPrefix = getUrlPrefix(cfg) #var links: seq[string] #for thread in tweets: @@ -108,7 +108,7 @@ ${renderRssTweet(quoteTweet, cfg)} ${getTitle(tweet, retweet)} @${tweet.user.username} - + ${getRfc822Time(tweet)} #if useGlobalGuid: ${tweet.id} @@ -121,7 +121,7 @@ ${renderRssTweet(quoteTweet, cfg)} #end for #end proc # -#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string = +#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string = #let urlPrefix = getUrlPrefix(cfg) #result = "" #let handle = (if multi: "" else: "@") & profile.user.username @@ -147,13 +147,13 @@ ${renderRssTweet(quoteTweet, cfg)} #let tweetsList = getTweetsWithPinned(profile) #if tweetsList.len > 0: -${renderRssTweets(tweetsList, cfg, userId=profile.user.id)} +${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)} #end if #end proc # -#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string = +#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string = #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}" #result = "" @@ -165,12 +165,12 @@ ${renderRssTweets(tweetsList, cfg, userId=profile.user.id)} ${getDescription(&"{list.name} by @{list.username}", cfg)} en-us 40 -${renderRssTweets(tweets, cfg)} +${renderRssTweets(tweets, cfg, prefs)} #end proc # -#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string = +#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string = #let link = &"{getUrlPrefix(cfg)}/search" #let escName = xmltree.escape(name) #result = "" @@ -183,7 +183,7 @@ ${renderRssTweets(tweets, cfg)} ${getDescription(&"Search \"{escName}\"", cfg)} en-us 40 -${renderRssTweets(tweets, cfg)} +${renderRssTweets(tweets, cfg, prefs)} #end proc From 40b1ba4e4ea323d9c12e15cbfc81749184e143e6 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 9 Feb 2026 22:08:09 +0100 Subject: [PATCH 186/309] Bump css version --- src/views/general.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/general.nim b/src/views/general.nim index 74ddcb4..8e01131 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=22") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=23") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: From 1c06a67afdf589f74756f1dcb330334ca92780d7 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 10 Feb 2026 22:42:06 +0100 Subject: [PATCH 187/309] Support image alt text Fixes #559 --- src/parser.nim | 16 ++++++++++++---- src/parserutils.nim | 2 +- src/routes/status.nim | 2 +- src/sass/tweet/media.scss | 35 +++++++++++++++++++++++++++++++---- src/types.nim | 6 +++++- src/views/general.nim | 2 +- src/views/renderutils.nim | 4 ++-- src/views/rss.nimf | 2 +- src/views/tweet.nim | 10 ++++++---- 9 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 9b4dc2f..f6b2ee7 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -139,7 +139,10 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = for m in jsMedia: case m.getTypeName: of "photo": - result.photos.add m{"media_url_https"}.getImageStr + result.photos.add Photo( + url: m{"media_url_https"}.getImageStr, + altText: m{"ext_alt_text"}.getStr + ) of "video": result.video = some(parseVideo(m)) with user, m{"additional_media_info", "source_user"}: @@ -165,7 +168,10 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) = with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: case mediaInfo.getTypeName of "ApiImage": - result.photos.add mediaInfo{"original_img_url"}.getImageStr + result.photos.add Photo( + url: mediaInfo{"original_img_url"}.getImageStr, + altText: mediaInfo{"alt_text"}.getStr + ) of "ApiVideo": let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} result.video = some Video( @@ -332,11 +338,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = let name = jsCard{"name"}.getStr if "poll" in name: if "image" in name: - result.photos.add jsCard{"binding_values", "image_large"}.getImageVal + result.photos.add Photo( + url: jsCard{"binding_values", "image_large"}.getImageVal + ) result.poll = some parsePoll(jsCard) elif name == "amplify": - result.video = some(parsePromoVideo(jsCard{"binding_values"})) + result.video = some parsePromoVideo(jsCard{"binding_values"}) else: result.card = some parseCard(jsCard, js{"entities", "urls"}) diff --git a/src/parserutils.nim b/src/parserutils.nim index b6ccd52..b88c8d2 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -333,7 +333,7 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = let url = - if t.photos.len > 0: t.photos[0] + if t.photos.len > 0: t.photos[0].url elif t.video.isSome: get(t.video).thumb elif t.gif.isSome: get(t.gif).thumb elif t.card.isSome: get(t.card).image diff --git a/src/routes/status.nim b/src/routes/status.nim index 838b327..7967f77 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -44,7 +44,7 @@ proc createStatusRouter*(cfg: Config) = desc = conv.tweet.text var - images = conv.tweet.photos + images = conv.tweet.photos.mapIt(it.url) video = "" if conv.tweet.video.isSome(): diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 66a300f..040edda 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -4,7 +4,6 @@ display: flex; flex-direction: row; flex-wrap: nowrap; - align-items: center; overflow: hidden; flex-grow: 1; max-height: 379.5px; @@ -13,7 +12,7 @@ .still-image { width: 100%; - display: flex; + align-self: center; } } @@ -58,7 +57,6 @@ .still-image { max-height: 379.5px; max-width: 533px; - justify-content: center; img { object-fit: cover; @@ -69,8 +67,37 @@ } } +.alt-text { + margin: 0px; + padding: 11px 7px; + box-sizing: border-box; + position: absolute; + bottom: 10px; + left: 10px; + width: 2.98em; + max-height: 25px; + white-space: pre; + overflow: hidden; + border-radius: 10px; + color: var(--fg_color); + font-size: 12px; + font-weight: bold; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(12px); +} + +.alt-text:hover { + padding: 7px; + width: Min(230px, calc(100% - 10px * 2)); + max-height: calc(100% - 10px); + line-height: 1.2em; + white-space: pre-wrap; + transition-duration: 0.4s; + transition-property: max-height; +} + .image { - display: inline-block; + display: flex; } // .single-image { diff --git a/src/types.nim b/src/types.nim index 90487ab..c90e789 100644 --- a/src/types.nim +++ b/src/types.nim @@ -137,6 +137,10 @@ type url*: string thumb*: string + Photo* = object + url*: string + altText*: string + GalleryPhoto* = object url*: string tweetId*: string @@ -217,7 +221,7 @@ type poll*: Option[Poll] gif*: Option[Gif] video*: Option[Video] - photos*: seq[string] + photos*: seq[Photo] Tweets* = seq[Tweet] diff --git a/src/views/general.nim b/src/views/general.nim index 8e01131..27fa103 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=23") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=24") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 6753c5a..0045a5a 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -97,9 +97,9 @@ proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocu label(`for`=pref): text label input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1") -proc genImg*(url: string; class=""): VNode = +proc genImg*(url: string; class=""; alt=""): VNode = buildHtml(): - img(src=getPicUrl(url), class=class, alt="", loading="lazy") + img(src=getPicUrl(url), class=class, alt=alt, loading="lazy") proc getTabClass*(query: Query; tab: QueryKind): string = if query.kind == tab: "tab-item active" diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 46d7eaf..6070238 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -56,7 +56,7 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}

${text.replace("\n", "
\n")}

#if tweet.photos.len > 0: # for photo in tweet.photos: - + # end for #elif tweet.video.isSome: diff --git a/src/views/tweet.nim b/src/views/tweet.nim index d680509..9263539 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -50,10 +50,12 @@ proc renderAlbum(tweet: Tweet): VNode = for photo in photos: tdiv(class="attachment image"): let - named = "name=" in photo - small = if named: photo else: photo & smallWebp - a(href=getOrigPicUrl(photo), class="still-image", target="_blank"): - genImg(small) + named = "name=" in photo.url + small = if named: photo.url else: photo.url & smallWebp + a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): + genImg(small, alt=photo.altText) + if photo.altText.len > 0: + p(class="alt-text"): text "ALT " & photo.altText proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = case playbackType From dcec1eb458886076f862622a5e841f82891df672 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 10 Feb 2026 22:53:54 +0100 Subject: [PATCH 188/309] Fix invalid search link formatting --- src/experimental/parser/slices.nim | 2 +- src/experimental/parser/user.nim | 2 +- src/formatters.nim | 2 +- src/parserutils.nim | 4 ++-- src/routes/search.nim | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/experimental/parser/slices.nim b/src/experimental/parser/slices.nim index 45e6e1d..db2c98d 100644 --- a/src/experimental/parser/slices.nim +++ b/src/experimental/parser/slices.nim @@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice]; let name = $runes[rep.slice.a.succ .. rep.slice.b] symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?q=%23" & name) + result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: result.add a($runes[rep.slice], href = rep.url, title = rep.display) of rkUrl: diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 8517bdc..8b98e7f 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -9,7 +9,7 @@ let unReplace = "$1@$2" htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)""" - htReplace = "$1$2$3" + htReplace = "$1$2$3" proc expandUserEntities(user: var User; raw: RawUser) = let diff --git a/src/formatters.nim b/src/formatters.nim index fc0e197..077c7c4 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -199,7 +199,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string = proc getLocation*(u: User | Tweet): (string, string) = if "://" in u.location: return (u.location, "") let loc = u.location.split(":") - let url = if loc.len > 1: "/search?q=place:" & loc[1] else: "" + let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: "" (loc[0], url) proc getSuspended*(username: string): string = diff --git a/src/parserutils.nim b/src/parserutils.nim index b88c8d2..426d025 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -17,7 +17,7 @@ let unReplace = "$1@$2" htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)" - htReplace = "$1$2$3" + htReplace = "$1$2$3" type ReplaceSliceKind = enum @@ -207,7 +207,7 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; let name = $runes[rep.slice.a.succ .. rep.slice.b] symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?q=%23" & name) + result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: result.add a($runes[rep.slice], href = rep.url, title = rep.display) of rkUrl: diff --git a/src/routes/search.nim b/src/routes/search.nim index 4220427..86677c9 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -43,9 +43,9 @@ proc createSearchRouter*(cfg: Config) = resp Http404, showError("Invalid search", cfg) get "/hashtag/@hash": - redirect("/search?q=" & encodeUrl("#" & @"hash")) + redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash")) get "/opensearch": - let url = getUrlPrefix(cfg) & "/search?q=" + let url = getUrlPrefix(cfg) & "/search?f=tweets&q=" resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, generateOpenSearchXML(cfg.title, cfg.hostname, url) From 05b6dd2a43a1672f6eadb601193f4ddfcf3804b8 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 11 Feb 2026 23:49:50 +0100 Subject: [PATCH 189/309] Add config options to enable subset of RSS feeds Fixes #1363 --- nitter.example.conf | 33 +++++++++++++++++++-------------- src/config.nim | 8 +++++++- src/routes/list.nim | 2 +- src/routes/rss.nim | 20 +++++++++++++++----- src/routes/search.nim | 2 +- src/routes/timeline.nim | 11 ++++++++++- src/types.nim | 6 +++++- src/views/general.nim | 4 ++-- 8 files changed, 60 insertions(+), 26 deletions(-) diff --git a/nitter.example.conf b/nitter.example.conf index 4a6a026..5263ebf 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -1,34 +1,39 @@ [Server] -hostname = "nitter.net" # for generating links, change this to your own domain/ip +hostname = "nitter.net" # for generating links, change this to your own domain/ip title = "nitter" address = "0.0.0.0" port = 8080 -https = false # disable to enable cookies when not using https +https = false # disable to enable cookies when not using https httpMaxConnections = 100 staticDir = "./public" [Cache] -listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) -rssMinutes = 10 # how long to cache rss queries -redisHost = "localhost" # Change to "nitter-redis" if using docker-compose +listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) +rssMinutes = 10 # how long to cache rss queries +redisHost = "localhost" # Change to "nitter-redis" if using docker-compose redisPort = 6379 redisPassword = "" -redisConnections = 20 # minimum open connections in pool +redisConnections = 20 # minimum open connections in pool redisMaxConnections = 30 # new connections are opened when none are available, but if the pool size # goes above this, they're closed when released. don't worry about this unless # you receive tons of requests per second [Config] -hmacKey = "secretkey" # random key for cryptographic signing of video urls -base64Media = false # use base64 encoding for proxied media urls -enableRSS = true # set this to false to disable RSS feeds -enableDebug = false # enable request logs and debug endpoints (/.sessions) -proxy = "" # http/https url, SOCKS proxies are not supported +hmacKey = "secretkey" # random key for cryptographic signing of video urls +base64Media = false # use base64 encoding for proxied media urls +enableRSS = true # master switch, set to false to disable all RSS feeds +enableRSSUserTweets = true # /@user/rss +enableRSSUserReplies = true # /@user/with_replies/rss +enableRSSUserMedia = true # /@user/media/rss +enableRSSSearch = true # /search/rss and /@user/search/rss +enableRSSList = true # list RSS feeds +enableDebug = false # enable request logs and debug endpoints (/.sessions) +proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" -apiProxy = "" # nitter-proxy host, e.g. localhost:7000 -disableTid = false # enable this if cookie-based auth is failing -maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions +apiProxy = "" # nitter-proxy host, e.g. localhost:7000 +disableTid = false # enable this if cookie-based auth is failing +maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/src/config.nim b/src/config.nim index 8cb334a..343b301 100644 --- a/src/config.nim +++ b/src/config.nim @@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = proc getConfig*(path: string): (Config, parseCfg.Config) = var cfg = loadConfig(path) + let masterRss = cfg.get("Config", "enableRSS", true) + let conf = Config( # Server address: cfg.get("Server", "address", "0.0.0.0"), @@ -37,7 +39,11 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = hmacKey: cfg.get("Config", "hmacKey", "secretkey"), base64Media: cfg.get("Config", "base64Media", false), minTokens: cfg.get("Config", "tokenCount", 10), - enableRss: cfg.get("Config", "enableRSS", true), + enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true), + enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true), + enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true), + enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true), + enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true), enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""), diff --git a/src/routes/list.nim b/src/routes/list.nim index 7dadc22..b4ab091 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) = let html = renderList(vnode, timeline.query, list) - rss = &"""/i/lists/{@"id"}/rss""" + rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: "" resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 6902001..444dc70 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -60,7 +60,8 @@ template respRss*(rss, page) = proc createRssRouter*(cfg: Config) = router rss: get "/search/rss": - cond cfg.enableRss + if not cfg.enableRSSSearch: + resp Http403, showError("RSS feed is disabled", cfg) if @"q".len > 200: resp Http400, showError("Search input too long.", cfg) @@ -86,8 +87,9 @@ proc createRssRouter*(cfg: Config) = respRss(rss, "Search") get "/@name/rss": - cond cfg.enableRss cond '.' notin @"name" + if not cfg.enableRSSUserTweets: + resp Http403, showError("RSS feed is disabled", cfg) let prefs = requestPrefs() name = @"name" @@ -103,9 +105,15 @@ proc createRssRouter*(cfg: Config) = respRss(rss, "User") get "/@name/@tab/rss": - cond cfg.enableRss cond '.' notin @"name" cond @"tab" in ["with_replies", "media", "search"] + let rssEnabled = case @"tab" + of "with_replies": cfg.enableRSSUserReplies + of "media": cfg.enableRSSUserMedia + of "search": cfg.enableRSSSearch + else: false + if not rssEnabled: + resp Http403, showError("RSS feed is disabled", cfg) let prefs = requestPrefs() name = @"name" @@ -132,8 +140,9 @@ proc createRssRouter*(cfg: Config) = respRss(rss, "User") get "/@name/lists/@slug/rss": - cond cfg.enableRss cond @"name" != "i" + if not cfg.enableRSSList: + resp Http403, showError("RSS feed is disabled", cfg) let slug = decodeUrl(@"slug") list = await getCachedList(@"name", slug) @@ -149,7 +158,8 @@ proc createRssRouter*(cfg: Config) = redirect(url) get "/i/lists/@id/rss": - cond cfg.enableRss + if not cfg.enableRSSList: + resp Http403, showError("RSS feed is disabled", cfg) let prefs = requestPrefs() id = @"id" diff --git a/src/routes/search.nim b/src/routes/search.nim index 86677c9..7d72f34 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -36,7 +36,7 @@ proc createSearchRouter*(cfg: Config) = of tweets: let tweets = await getGraphTweetSearch(query, getCursor()) - rss = "/search/rss?" & genQueryUrl(query) + rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: "" resp renderMain(renderTweetSearch(tweets, prefs, getPath()), request, cfg, prefs, title, rss=rss) else: diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index d6d8c21..379281a 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -138,8 +138,17 @@ proc createTimelineRouter*(cfg: Config) = profile.tweets.beginning = true resp $renderTimelineTweets(profile.tweets, prefs, getPath()) + let rssEnabled = + if @"tab".len == 0: cfg.enableRSSUserTweets + elif @"tab" == "with_replies": cfg.enableRSSUserReplies + elif @"tab" == "media": cfg.enableRSSUserMedia + elif @"tab" == "search": cfg.enableRSSSearch + else: false + let rss = - if @"tab".len == 0: + if not rssEnabled: + "" + elif @"tab".len == 0: "/$1/rss" % @"name" elif @"tab" == "search": "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] diff --git a/src/types.nim b/src/types.nim index c90e789..33298d2 100644 --- a/src/types.nim +++ b/src/types.nim @@ -275,7 +275,11 @@ type hmacKey*: string base64Media*: bool minTokens*: int - enableRss*: bool + enableRSSUserTweets*: bool + enableRSSUserReplies*: bool + enableRSSUserMedia*: bool + enableRSSSearch*: bool + enableRSSList*: bool enableDebug*: bool proxy*: string proxyAuth*: string diff --git a/src/views/general.nim b/src/views/general.nim index 27fa103..32e53bd 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -29,7 +29,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = tdiv(class="nav-item right"): icon "search", title="Search", href="/search" - if cfg.enableRss and rss.len > 0: + if rss.len > 0: icon "rss", title="RSS Feed", href=rss icon "bird", title="Open in X", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp @@ -67,7 +67,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if alternate.len > 0: link(rel="alternate", href=alternate, title="View on X") - if cfg.enableRss and rss.len > 0: + if rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") if prefs.hlsPlayback: From cbce620692adb8a9ce03177e3660c4a078d7c819 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 12 Feb 2026 20:16:50 +0100 Subject: [PATCH 190/309] Add dynamic-range-limit to prevent HDR jumpscares Fixes #1345 --- src/sass/index.scss | 4 ++++ src/views/general.nim | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sass/index.scss b/src/sass/index.scss index a85002e..ea7227d 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -62,6 +62,10 @@ body { text-decoration: none; } +img { + dynamic-range-limit: standard; +} + h1 { display: inline; } diff --git a/src/views/general.nim b/src/views/general.nim index 32e53bd..30ebb3c 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=24") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=25") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: From 90b664ffb7c3b74de4475078b5e6e4c77cce7806 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 14 Feb 2026 02:19:53 +0100 Subject: [PATCH 191/309] Make "Tweet unavailable" clickable and consistent --- src/parser.nim | 32 ++++++++++++++++++++------------ src/parserutils.nim | 1 - src/sass/tweet/quote.scss | 1 + src/views/timeline.nim | 8 +++----- src/views/tweet.nim | 10 +++------- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index f6b2ee7..c4e25a6 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -417,21 +417,21 @@ proc parseGraphTweet(js: JsonNode): Tweet = parseMediaEntities(js, result) - if result.quote.isSome: - result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) - - with quoted, js{"quotedPostResults", "result"}: + with quoted, js{"quoted_status_result", "result"}: result.quote = some(parseGraphTweet(quoted)) + with quoted, js{"quotedPostResults"}: + if "result" in quoted: + result.quote = some(parseGraphTweet(quoted{"result"})) + else: + result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId) + proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = for t in ? js{"content", "items"}: let entryId = t.getEntryId - if "cursor-showmore" in entryId: - let cursor = t{"item", "content", "value"} - result.thread.cursor = cursor.getStr - result.thread.hasMore = true - elif "tweet" in entryId and "promoted" notin entryId: - with tweet, t.getTweetResult("item"): + if "tweet-" in entryId and "promoted" notin entryId: + let tweet = t.getTweetResult("item") + if not tweet.isNull: result.thread.content.add parseGraphTweet(tweet) let tweetDisplayType = select( @@ -440,6 +440,12 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = ) if tweetDisplayType.getStr == "SelfThread": result.self = true + else: + result.thread.content.add Tweet(id: entryId.getId) + elif "cursor-showmore" in entryId: + let cursor = t{"item", "content", "value"} + result.thread.cursor = cursor.getStr + result.thread.hasMore = true proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: @@ -460,7 +466,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if i.getTypeName == "TimelineAddEntries": for e in i{"entries"}: let entryId = e.getEntryId - if entryId.startsWith("tweet"): + if entryId.startsWith("tweet-"): let tweetResult = getTweetResult(e) if tweetResult.notNull: let tweet = parseGraphTweet(tweetResult) @@ -468,10 +474,12 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if not tweet.available: tweet.id = entryId.getId - if $tweet.id == tweetId: + if entryId.endsWith(tweetId): result.tweet = tweet else: result.before.content.add tweet + elif not entryId.endsWith(tweetId): + result.before.content.add Tweet(id: entryId.getId) elif entryId.startsWith("conversationthread"): let (thread, self) = parseGraphThread(e) if self: diff --git a/src/parserutils.nim b/src/parserutils.nim index 426d025..08b3cf8 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string = template getEntryId*(e: JsonNode): string = e{"entryId"}.getStr(e{"entry_id"}.getStr) - template parseTime(time: string; f: static string; flen: int): DateTime = if time.len != flen: return parse(time, f, utc()) diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index 1db4f7e..5894735 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -43,6 +43,7 @@ .unavailable-quote { padding: 12px; + display: block; } .quote-link { diff --git a/src/views/timeline.nim b/src/views/timeline.nim index fee45bc..317b7e3 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -53,7 +53,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = let show = i == thread.high and sortedThread[0].id != tweet.threadId let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" renderTweet(tweet, prefs, path, class=(header & "thread"), - index=i, last=(i == thread.high), showThread=show) + index=i, last=(i == thread.high)) proc renderUser(user: User; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-item", data-username=user.username)): @@ -96,7 +96,7 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; if not prefs.hidePins and pinned.isSome: let tweet = get pinned - renderTweet(tweet, prefs, path, showThread=tweet.hasThread) + renderTweet(tweet, prefs, path) if results.content.len == 0: if not results.beginning: @@ -116,11 +116,9 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; tweet.pinned and prefs.hidePins: continue - var hasThread = tweet.hasThread if retweetId != 0 and tweet.retweet.isSome: retweets &= retweetId - hasThread = get(tweet.retweet).hasThread - renderTweet(tweet, prefs, path, showThread=hasThread) + renderTweet(tweet, prefs, path) else: renderThread(thread, prefs, path) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 9263539..0c6c6a1 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -223,7 +223,7 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if not quote.available: return buildHtml(tdiv(class="quote unavailable")): - tdiv(class="unavailable-quote"): + a(class="unavailable-quote", href=getLink(quote, focus=false)): if quote.tombstone.len > 0: text quote.tombstone elif quote.text.len > 0: @@ -271,14 +271,14 @@ proc renderLocation*(tweet: Tweet): string = return $node proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; - last=false; showThread=false; mainTweet=false; afterTweet=false): VNode = + last=false; mainTweet=false; afterTweet=false): VNode = var divClass = class if index == -1 or last: divClass = "thread-last " & class if not tweet.available: return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)): - tdiv(class="unavailable-box"): + a(class="unavailable-box", href=getLink(tweet)): if tweet.tombstone.len > 0: text tweet.tombstone elif tweet.text.len > 0: @@ -345,10 +345,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if not prefs.hideTweetStats: renderStats(tweet.stats) - if showThread: - a(class="show-thread", href=("/i/status/" & $tweet.threadId)): - text "Show this thread" - proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req) From d45545cd539d16cfb0d7353831bd79044fd9345a Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 14 Feb 2026 02:24:24 +0100 Subject: [PATCH 192/309] Fix "Replying to" parsing --- src/parser.nim | 17 +++++++++++++---- src/views/tweet.nim | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index c4e25a6..0ea91bc 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -283,7 +283,8 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = result.url.len == 0 or result.url.startsWith("card://"): result.url = getPicUrl(result.image) -proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = +proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); + replyId: int64 = 0): Tweet = if js.isNull: return let time = @@ -307,6 +308,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = ) ) + if result.replyId == 0: + result.replyId = replyId + # fix for pinned threads if result.hasThread and result.threadId == 0: result.threadId = js{"self_thread", "id_str"}.getId @@ -402,12 +406,17 @@ proc parseGraphTweet(js: JsonNode): Tweet = "binding_values": %bindingObj } - result = parseTweet(js{"legacy"}, jsCard) + var replyId = 0 + with restId, js{"reply_to_results", "rest_id"}: + replyId = restId.getId + + result = parseTweet(js{"legacy"}, jsCard, replyId) result.id = js{"rest_id"}.getId result.user = parseGraphUser(js{"core"}) - if result.replyId == 0: - result.replyId = js{"reply_to_results", "rest_id"}.getId + if result.reply.len == 0: + with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}: + result.reply = @[replyTo.getStr] with count, js{"views", "count"}: result.stats.views = count.getStr("0").parseInt diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 0c6c6a1..2db01c1 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -307,7 +307,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; renderHeader(tweet, retweet, pinned, prefs) if not afterTweet and index == 0 and tweet.reply.len > 0 and - (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username): + (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned): renderReply(tweet) var tweetClass = "tweet-content media-body" From f257ce53ae2cfdea36c7f3d91b6bca79ce07b63f Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 14 Feb 2026 02:38:10 +0100 Subject: [PATCH 193/309] Bump style version --- src/views/general.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/general.nim b/src/views/general.nim index 30ebb3c..23e6a10 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=25") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=26") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: From a15d1ce16bf9e8227f95bbcf2a8cbcf6dff87857 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 16 Feb 2026 00:52:17 +0100 Subject: [PATCH 194/309] Add full support for tweet edit history Fixes #700 --- src/api.nim | 7 ++++ src/consts.nim | 74 +++++++++++++++++++++----------------- src/formatters.nim | 12 ++++--- src/parser.nim | 29 ++++++++++++++- src/routes/status.nim | 22 +++++++++++- src/sass/timeline.scss | 9 ++--- src/sass/tweet/_base.scss | 16 +++++++-- src/sass/tweet/quote.scss | 5 +++ src/sass/tweet/thread.scss | 31 +++++++++++----- src/types.nim | 5 +++ src/views/general.nim | 2 +- src/views/status.nim | 14 ++++++++ src/views/tweet.nim | 28 +++++++++++++-- 13 files changed, 194 insertions(+), 60 deletions(-) diff --git a/src/api.nim b/src/api.nim index acd25f1..ef44bd0 100644 --- a/src/api.nim +++ b/src/api.nim @@ -138,6 +138,13 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = if after.len > 0: result.replies = await getReplies(id, after) +proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} = + if id.len == 0: return + let + url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id) + js = await fetch(url) + result = parseGraphEditHistory(js, id) + proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = let q = genQueryParam(query) if q.len == 0 or q == emptyQuery: diff --git a/src/consts.nim b/src/consts.nim index 6456efc..38fd870 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -19,6 +19,7 @@ const graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" + graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory" graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" @@ -29,35 +30,67 @@ const "android_ad_formats_media_component_render_overlay_enabled": false, "android_graphql_skip_api_media_color_palette": false, "android_professional_link_spotlight_display_enabled": false, + "articles_api_enabled": false, + "articles_preview_enabled": true, "blue_business_profile_image_shape_enabled": false, + "c9s_tweet_anatomy_moderator_badge_enabled": true, "commerce_android_shop_module_enabled": false, + "communities_web_enable_tweet_community_results_fetch": true, + "creator_subscriptions_quote_tweet_preview_enabled": false, "creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": true, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, + "grok_android_analyze_trend_fetch_enabled": false, + "grok_translations_community_note_auto_translation_is_enabled": false, + "grok_translations_community_note_translation_is_enabled": false, + "grok_translations_post_auto_translation_is_enabled": false, + "grok_translations_timeline_user_bio_auto_translation_is_enabled": false, "hidden_profile_likes_enabled": false, "highlights_tweets_tab_ui_enabled": false, + "immersive_video_status_linkable_timestamps": false, "interactive_text_enabled": false, "longform_notetweets_consumption_enabled": true, "longform_notetweets_inline_media_enabled": true, - "longform_notetweets_rich_text_read_enabled": true, "longform_notetweets_richtext_consumption_enabled": true, + "longform_notetweets_rich_text_read_enabled": true, "mobile_app_spotlight_module_enabled": false, + "payments_enabled": false, + "post_ctas_fetch_enabled": true, + "premium_content_api_read_enabled": false, + "profile_label_improvements_pcf_label_in_post_enabled": true, + "profile_label_improvements_pcf_label_in_profile_enabled": false, "responsive_web_edit_tweet_api_enabled": true, "responsive_web_enhance_cards_enabled": false, "responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_timeline_navigation_enabled": true, + "responsive_web_grok_analysis_button_from_backend": true, + "responsive_web_grok_analyze_button_fetch_trends_enabled": false, + "responsive_web_grok_analyze_post_followups_enabled": true, + "responsive_web_grok_annotations_enabled": true, + "responsive_web_grok_community_note_auto_translation_is_enabled": false, + "responsive_web_grok_image_annotation_enabled": true, + "responsive_web_grok_imagine_annotation_enabled": true, + "responsive_web_grok_share_attachment_enabled": true, + "responsive_web_grok_show_grok_translated_post": false, + "responsive_web_jetfuel_frame": true, "responsive_web_media_download_video_enabled": false, + "responsive_web_profile_redirect_enabled": false, "responsive_web_text_conversations_enabled": false, + "responsive_web_twitter_article_notes_tab_enabled": false, "responsive_web_twitter_article_tweet_consumption_enabled": true, - "unified_cards_destination_url_params_enabled": false, "responsive_web_twitter_blue_verified_badge_is_enabled": true, "rweb_lists_timeline_redesign_enabled": true, + "rweb_tipjar_consumption_enabled": true, + "rweb_video_screen_enabled": false, + "rweb_video_timestamps_enabled": false, "spaces_2022_h2_clipping": true, "spaces_2022_h2_spaces_communities": true, "standardized_nudges_misinfo": true, + "subscriptions_feature_can_gift_premium": false, "subscriptions_verification_info_enabled": true, + "subscriptions_verification_info_is_identity_verified_enabled": false, "subscriptions_verification_info_reason_enabled": true, "subscriptions_verification_info_verified_since_enabled": true, "super_follow_badge_privacy_enabled": false, @@ -68,40 +101,10 @@ const "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, "tweetypie_unmention_optimization_enabled": false, "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, + "unified_cards_destination_url_params_enabled": false, "verified_phone_label_enabled": false, "vibe_api_enabled": false, "view_counts_everywhere_api_enabled": true, - "premium_content_api_read_enabled": false, - "communities_web_enable_tweet_community_results_fetch": true, - "responsive_web_jetfuel_frame": true, - "responsive_web_grok_analyze_button_fetch_trends_enabled": false, - "responsive_web_grok_image_annotation_enabled": true, - "responsive_web_grok_imagine_annotation_enabled": true, - "rweb_tipjar_consumption_enabled": true, - "profile_label_improvements_pcf_label_in_post_enabled": true, - "creator_subscriptions_quote_tweet_preview_enabled": false, - "c9s_tweet_anatomy_moderator_badge_enabled": true, - "responsive_web_grok_analyze_post_followups_enabled": true, - "rweb_video_timestamps_enabled": false, - "responsive_web_grok_share_attachment_enabled": true, - "articles_preview_enabled": true, - "immersive_video_status_linkable_timestamps": false, - "articles_api_enabled": false, - "responsive_web_grok_analysis_button_from_backend": true, - "rweb_video_screen_enabled": false, - "payments_enabled": false, - "responsive_web_profile_redirect_enabled": false, - "responsive_web_grok_show_grok_translated_post": false, - "responsive_web_grok_community_note_auto_translation_is_enabled": false, - "profile_label_improvements_pcf_label_in_profile_enabled": false, - "grok_android_analyze_trend_fetch_enabled": false, - "grok_translations_community_note_auto_translation_is_enabled": false, - "grok_translations_post_auto_translation_is_enabled": false, - "grok_translations_community_note_translation_is_enabled": false, - "grok_translations_timeline_user_bio_auto_translation_is_enabled": false, - "subscriptions_feature_can_gift_premium": false, - "responsive_web_twitter_article_notes_tab_enabled": false, - "subscriptions_verification_info_is_identity_verified_enabled": false, "hidden_profile_subscriptions_enabled": false }""".replace(" ", "").replace("\n", "") @@ -128,6 +131,11 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") + tweetEditHistoryVars* = """{ + "tweetId": "$1", + "withQuickPromoteEligibilityTweetFields": true +}""".replace(" ", "").replace("\n", "") + restIdVars* = """{ "rest_id": "$1", $2 "count": 20 diff --git a/src/formatters.nim b/src/formatters.nim index 077c7c4..5d06535 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -165,13 +165,17 @@ proc getDuration*(video: Video): string = else: return &"{min mod 60}:{sec mod 60:02}" +proc getLink*(id: int64; username="i"; focus=true): string = + var username = username + if username.len == 0: + username = "i" + result = &"/{username}/status/{id}" + if focus: result &= "#m" + proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return var username = tweet.user.username - if username.len == 0: - username = "i" - result = &"/{username}/status/{tweet.id}" - if focus: result &= "#m" + return getLink(tweet.id, username, focus) proc getTwitterLink*(path: string; params: Table[string, string]): string = var diff --git a/src/parser.nim b/src/parser.nim index 0ea91bc..6b8da60 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -435,12 +435,16 @@ proc parseGraphTweet(js: JsonNode): Tweet = else: result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId) + with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}: + for id in ids: + result.history.add parseBiggestInt(id.getStr) + proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = for t in ? js{"content", "items"}: let entryId = t.getEntryId if "tweet-" in entryId and "promoted" notin entryId: let tweet = t.getTweetResult("item") - if not tweet.isNull: + if tweet.notNull: result.thread.content.add parseGraphTweet(tweet) let tweetDisplayType = select( @@ -516,6 +520,29 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = ) result.replies.bottom = cursorValue.getStr +proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory = + let instructions = ? js{ + "data", "tweet_result_by_rest_id", "result", + "edit_history_timeline", "timeline", "instructions" + } + if instructions.len == 0: + return + + for i in instructions: + if i.getTypeName == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e.getEntryId + if entryId == "latestTweet": + with item, e{"content", "items"}[0]: + let tweetResult = item.getTweetResult("item") + if tweetResult.notNull: + result.latest = parseGraphTweet(tweetResult) + elif entryId == "staleTweets": + for item in e{"content", "items"}: + let tweetResult = item.getTweetResult("item") + if tweetResult.notNull: + result.history.add parseGraphTweet(tweetResult) + proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = with tweetResult, getTweetResult(e): var tweet = parseGraphTweet(tweetResult) diff --git a/src/routes/status.nim b/src/routes/status.nim index 7967f77..7322837 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -64,9 +64,29 @@ proc createStatusRouter*(cfg: Config) = resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, images=images, video=video) + get "/@name/status/@id/history/?": + cond '.' notin @"name" + let id = @"id" + + if id.len > 19 or id.any(c => not c.isDigit): + resp Http404, showError("Invalid tweet ID", cfg) + + let edits = await getGraphEditHistory(id) + if edits.latest == nil or edits.latest.id == 0: + resp Http404, showError("Tweet history not found", cfg) + + let + prefs = requestPrefs() + title = "History for " & pageTitle(edits.latest) + ogTitle = "Edit History for " & pageTitle(edits.latest.user) + desc = edits.latest.text + + let html = renderEditHistory(edits, prefs, getPath()) + resp renderMain(html, request, cfg, prefs, title, desc, ogTitle) + get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] - cond @"m" in ["video", "photo", "history"] + cond @"m" in ["video", "photo"] redirect("/$1/status/$2" % [@"name", @"id"]) get "/@name/statuses/@id/?": diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index 40882b2..eeb794a 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -4,12 +4,8 @@ @include panel(100%, 600px); } -.timeline { - background-color: var(--bg_panel); - - > div:not(:first-child) { - border-top: 1px solid var(--border_grey); - } +.timeline > div:not(:first-child) { + border-top: 1px solid var(--border_grey); } .timeline-header { @@ -159,4 +155,5 @@ padding: 0.75em; display: flex; position: relative; + background-color: var(--bg_panel); } diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 7f2d931..28590e7 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -80,8 +80,8 @@ } .tweet-published { - margin: 0; - margin-top: 5px; + margin-top: 10px; + margin-bottom: 3px; color: var(--grey); pointer-events: all; } @@ -242,3 +242,15 @@ background-color: var(--bg_hover); } } + +.latest-post-version { + border-bottom: 1px solid var(--dark_grey); + border-top: 1px solid var(--dark_grey); + padding: 01ch 0px; + margin: 1ch 0px; + color: var(--grey); + + a { + pointer-events: all; + } +} diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index 5894735..64d7e13 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -35,6 +35,11 @@ margin-top: -6px; } + .quote-latest { + padding: 0px 8px 6px 8px; + color: var(--grey); + } + .replying-to { padding: 0px 8px; margin: unset; diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index d9bc457..c5165d7 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -1,7 +1,8 @@ @import "_variables"; @import "_mixins"; -.conversation { +.conversation, +.edit-history { @include panel(100%, 600px); .show-more { @@ -9,19 +10,36 @@ } } -.main-thread { +.main-thread, +.latest-edit { margin-bottom: 20px; - background-color: var(--bg_panel); +} + +.reply { + margin-bottom: 10px; } .main-tweet, -.replies { +.replies, +.edit-history > div { body.fixed-nav & { padding-top: 50px; margin-top: -50px; } } +.edit-history-header { + padding: 10px; + margin-bottom: 5px; + font-size: 16px; + font-weight: bold; + background-color: var(--bg_panel); +} + +.tweet-edit { + margin-bottom: 5px; +} + .main-tweet .tweet-content { font-size: 18px; } @@ -32,11 +50,6 @@ } } -.reply { - background-color: var(--bg_panel); - margin-bottom: 10px; -} - .thread-line { .timeline-item::before, &.timeline-item::before { diff --git a/src/types.nim b/src/types.nim index 33298d2..996f572 100644 --- a/src/types.nim +++ b/src/types.nim @@ -222,6 +222,7 @@ type gif*: Option[Gif] video*: Option[Video] photos*: seq[Photo] + history*: seq[int64] Tweets* = seq[Tweet] @@ -242,6 +243,10 @@ type after*: Chain replies*: Result[Chain] + EditHistory* = object + latest*: Tweet + history*: Tweets + Timeline* = Result[Tweets] Profile* = object diff --git a/src/views/general.nim b/src/views/general.nim index 23e6a10..d002777 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=26") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=27") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: diff --git a/src/views/status.nim b/src/views/status.nim index 96af807..fdb4ae9 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -78,3 +78,17 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode renderReplies(conv.replies, prefs, path, conv.tweet) renderToTop(focus="#m") + +proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="edit-history")): + tdiv(class="latest-edit"): + tdiv(class="edit-history-header"): + text "Latest post" + renderTweet(edits.latest, prefs, path) + + tdiv(class="previous-edits"): + tdiv(class="edit-history-header"): + text "Version history" + for tweet in edits.history: + tdiv(class="tweet-edit"): + renderTweet(tweet, prefs, path) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 2db01c1..f79c571 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -211,6 +211,12 @@ proc renderMediaTags(tags: seq[User]): VNode = if i < tags.high: text ", " +proc renderLatestPost(username: string; id: int64): VNode = + buildHtml(tdiv(class="latest-post-version")): + text "There's a new version of this post. " + a(href=getLink(id, username)): + text "See the latest post" + proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="quote-media-container")): if quote.photos.len > 0: @@ -252,12 +258,16 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="quote-text", dir="auto"): verbatim replaceUrls(quote.text, prefs) + if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: + renderQuoteMedia(quote, prefs, path) + if quote.hasThread: a(class="show-thread", href=getLink(quote)): text "Show this thread" - if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: - renderQuoteMedia(quote, prefs, path) + if quote.history.len > 0 and quote.id != max(quote.history): + tdiv(class="quote-latest"): + text "There's a new version of this post" proc renderLocation*(tweet: Tweet): string = let (place, url) = tweet.getLocation() @@ -336,8 +346,20 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.quote.isSome: renderQuote(tweet.quote.get(), prefs, path) + let + hasEdits = tweet.history.len > 1 + isLatest = hasEdits and tweet.id == max(tweet.history) + if mainTweet: - p(class="tweet-published"): text &"{getTime(tweet)}" + p(class="tweet-published"): + if hasEdits and isLatest: + a(href=(getLink(tweet, focus=false) & "/history")): + text &"Last edited {getTime(tweet)}" + else: + text &"{getTime(tweet)}" + + if hasEdits and not isLatest: + renderLatestPost(tweet.user.username, max(tweet.history)) if tweet.mediaTags.len > 0: renderMediaTags(tweet.mediaTags) From 2bd664ae7d3097bc0d3edc9a44282bddbcfb4f06 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 19 Feb 2026 01:44:50 +0100 Subject: [PATCH 195/309] Add community notes support Fixes #727 Fixes #1023 --- public/css/fontello.css | 17 ++-- public/fonts/fontello.eot | Bin 9544 -> 9820 bytes public/fonts/fontello.svg | 4 +- public/fonts/fontello.ttf | Bin 9376 -> 9652 bytes public/fonts/fontello.woff | Bin 5924 -> 6152 bytes public/fonts/fontello.woff2 | Bin 4896 -> 5068 bytes src/consts.nim | 2 +- src/parser.nim | 9 ++ src/parserutils.nim | 20 +++++ src/prefs_impl.nim | 3 + src/sass/tweet/_base.scss | 35 ++++++++ src/sass/tweet/card.scss | 158 ++++++++++++++++++------------------ src/sass/tweet/quote.scss | 30 +++++-- src/types.nim | 1 + src/views/general.nim | 2 +- src/views/tweet.nim | 14 ++++ 16 files changed, 201 insertions(+), 94 deletions(-) diff --git a/public/css/fontello.css b/public/css/fontello.css index 52362d8..53a66a1 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,12 +1,12 @@ @font-face { font-family: "fontello"; - src: url("/fonts/fontello.eot?77185648"); + src: url("/fonts/fontello.eot?42791196"); src: - url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"), - url("/fonts/fontello.woff2?77185648") format("woff2"), - url("/fonts/fontello.woff?77185648") format("woff"), - url("/fonts/fontello.ttf?77185648") format("truetype"), - url("/fonts/fontello.svg?77185648#fontello") format("svg"); + url("/fonts/fontello.eot?42791196#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?42791196") format("woff2"), + url("/fonts/fontello.woff?42791196") format("woff"), + url("/fonts/fontello.ttf?42791196") format("truetype"), + url("/fonts/fontello.svg?42791196#fontello") format("svg"); font-weight: normal; font-style: normal; } @@ -56,6 +56,11 @@ } /* '' */ +.icon-group:before { + content: "\e804"; +} + +/* '' */ .icon-play:before { content: "\e805"; } diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 8671134d7bcae39b2c2b05872362415a5a3bfbe7..2e05e0b254729c644c52a1291e36fa9d9c553a37 100644 GIT binary patch delta 964 zcmYLIUr19?82`?>cXxBnP4BilZ8N8Hn{zgu?oXnog_wxch>$cvr}-z@+(k_+&`G5T zL1I^^hYFz=p@?X)mmZ3qB(SG~f}rRh_bCwVAu#N_dZT;!o$vSizH`s{zVDp7(ebg9 z>-7LUbFXj_HuyA{S!ubEehWZi0MI{}jEk?kHD^&j0ibJx(^F3L<0I_iU>p`lk^_O? zR{(4Vl!lS`q=^1}jPIb;jU+A)w_dB{09ahl2%|&s3xSQiJ^&syL`Kn}y~;d5y@a}a zG&wcX(cHC%i}R1raW0V>jC*>@t^=4^)PZDtMkMvd`=~>xo#XN3(B{43!>BPyx+$h6 zr(VwaqnJQB#@~n&L*iVvAq(Ixl-9_3(l70j)ag|q7zMZq%s#6^tH&Z&frSC|>p=$; zs9dmBuvM|0v$qEz!+*a2El$UIRmv;9qJE7P9b9IMQj!nY3{?9g601bnx-{l5D>Z1) zssL<^tOB&iRp9O*tAHHjDwG^$6_ATug|eTl0`icnz>b5gq617QR46ATtAGOJDu4$p z^8jE*TX6ue0BmCt3c#A#Vc(H7gA^ET-umoWXWO-b4*MQS?H%Q|}It-ua$WO}7fE>(5uC zg?wlYl(>RE*YS1UK3zL*8e2TX#k{>ot9{fwE9UB=wx;Exb-TXJVe6{vOe8ulh7Y&t z?dxuq7Lx9 z;zbYE^%M|-UIJc(g&f3lwTB$-DF+V#b(A@ugvTaQu#4rpQ-3z-4`{QqvR9tyj$ZWjG0CE8sLMd%hCi*}-&)c!!P2YB!T z|6Tuk)d>ZnMPK=y%XGg}& Ezs>xbegFUf diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 31bd38c..ccc0436 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -1,7 +1,7 @@ -Copyright (C) 2025 by original authors @ fontello.com +Copyright (C) 2026 by original authors @ fontello.com @@ -14,6 +14,8 @@ + + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index 0c04c6c934c632486fc0fc56ce07b794a1fcba07..b4dcf100b9c57f6507424657c3d0dc77de2ffd11 100644 GIT binary patch delta 985 zcmYL|OH30{6o${e^Jpohr7dlN@+g!RO0iJr0|Z13K}4dcgb0#A!SV{FV<{C)phShR z@--}6s3CDfqA|vrZd~ZnnBYnmu3Y%YtTY5o)M)SwjIqr(=l}nEJ9F=u+x4SQkKJ&j z-T;su02m&PhJ{!CTrc{^0CascnXqGyAIopN=*EQc=!o~{B><}yt#&*-D`1|BkHh06QZofC{w&J|83n z$U?0kuToNgY}5*XPm&ZM2eksW93+J<(4$e1Uy-B$2Gj~*1l;HzV8U9p2jl^4<053h zobG1dk`#jy7>Q=U0(Ec%_8qKlZg3Upw5*B%>6SaV%%Xy2ju3_{1H&Y3E}PBm=J_H| zEAh7xSEWA`4AonCE3YTK%Iqk&2K;T*OG5r&y~UiTDkYX;94PY=60DSG%ApmrJXOjO z@YfQT@xfv0a0W{4zD{jUypD|*Jh$9#a(c<&(!lZ7Tlv)S=YuYfyleNCI(%-&p$(%g zSwEwnS}EjvDhC^D-PE)oWUE8g*6RfuHcdy7wXdOjdb)cuP|~5XZ8%w)Px_}8Ow{8( zaLU)Cv8>qX^(I?DO(1RO{n42$?K$PB*YugUGtv zGcjX0?U^F0l&3TF5mUu1F&VanU1C3TgItFD!sqgL@F>V<3{Nt2rt6Fc*s#tJ$MoJB!(hX6ylq##a({$=DlxcXLe@aUD;XO9^dK! zA`jqJ$ujKm-8UZnTR>@+)}2x2ywX0KSlO;vx2Jw>(jU-9D@Ma+ew6+zHBwo9SYFtC zz5r-ffgw~)^`ra6T^Y6}f<8tnb=urv0dWvCVgDFAi>qWw#*e z#U=!cz)Pt8-X!%1zp)7oRpwDdpg{9gKhaHev-<OK4ubqjAXX3jurLXv2J9M2sYi~j{} C37C5T diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff index e4582ad0ee7148607d5a5e183e595a785167af4e..af578282692a25df27b6863c12ff80a911d4d12f 100644 GIT binary patch delta 4070 zcmXY!bySqk!^NKkcIggLQo2RHbV@HNh$y8j4Z_0Gupkd9-3mx4h%`vI5&|yLAV`DY z(j6lGTi^4}IiE9g@7#N4&ipq8B2l7rz7I7t00@8^j6?wCznQx(nNA7(VS}_n0RUtQ z=X?PGy!YQtp^t5Ryx4K;44g-bg8~O&k8(ibyaE7#?cgjjYmqawgOw+)OZ*zQP4xdz zIk@=Sl0ZKaz_7dIPRke&~xMgGBK#u1LR^1~fWZ-Ue8|A5F)-4``?yRdj-ieqK`E4n9j<#T4yVyTsv!i9r;kX@g2;eKS+Wqn?41;4 zk1$ax0&_;W&b!rGU2K8-Wry#x4>8S;f`S!Djj#`GOP*+z(HqR-{plF8(4*W*)adzC ziyYjOt~|?S`;^!=HtJ+ni1lFF^`#p2&u`vsQIy!bOjxj#-CrILJ6&M(Ukjsk;88qV z&YJ#6cx_Xj7jHT^u^6v*n75RhZ*WcbJx`_C7`10u1*O}@hh{0~WeZL*kev9ENJfP| z%SV*)P2D4rj1SGZm~@#^?6>=R8a!Loc?p>E%D{ozR6HU=@aanzqo4HcYYV4Xhl{fd zg>OF0VE1rzj&zBQ5)-etE{wlQi1;=+G3h&4dGuKXy`df^pCdMa;6x0rDvd1)oLw` z$u%vsnmlA!Y9aXDQ5cCJ+sMe#iHsjL&y&z&3P>oMq`Di;t%FGmijb7S;4aQ<9sy|W z`wKg!jEIysZjd(7m#C_s!XSH86}tXO<>OBy5i?o%C*W3<-O`}h$47jR_ucCb28@m+ z@>76~ayx6NRBBGELX)r#4L zw8Q$;%)pcb=fc=RyFXzQl*TiZGVoiab-Zj)ABT;w)Qy4d#IB0c-r@&uRZ-`5?dCpu zx?k73;x|&+ggPrf-}*xopr;N*l02r)(D~uTO>e zwT}&#>tGQtg1+s4=vU6PIBQ*vYn=VZJs8^=$?^$_9PRrG~I4a;p8+&Pf=#F$V@6u z#ZTWVrn#(xf2AD@v1f=Lr5vbn%7dS&*7~EcFel>>7sH{&7Tms%AWy4n`ls`k9_4D{}sKNAi!F;*ZH|j?)~k2 zN_~ugG?$OAPjE|p5*n315vmfyqC;O?RI5$cOBk+wb$zA!lPB-2sI;agOipd&mzPp|l8ORuZegn+RYb@!SqnmFl{&a1=GBjIejdjg)8D3)X_himfvm5B6)+x9*^gc zQ)q6n(ljmZiyc?RgKOOT^1Zh8kb4Fg&QFu4C5H81hFuqKl3L?!u*jA6zuX)< zkdVtQ;1$)};re#Q%Cg9pf;rJqd%_iytzqxNfz|8g0@*@#84RKmynOzm<*(HqvX4Z; z=$eU5Aft@boT;{VFqnNZa=lyStdy|gUjc1vokCv4uJn|D8!E3_Z$Bos5WkQj>rg9^ zQT4R)%a=)Y64C3Jd#P?rA9VJyQ1!~jv{UxiVXNejinvkV1I=dq&As8u9W3uQoioo4 z*F(u4lVFiqYBCCLJ>n{PyCsV{LM(mtwc3E@_mR=~<-~ZJUBUo7ioY&}g6Vw@N7ZRO z$-W{{x~lp%v`M;IPBPC_s(u-6M|$;jFcf|piBGx9nXMN0fP!n=MqgM!#p(BjQaXoYx9H&-NA zzI~uocMUNqX?`fBmtX(hj=vS#Jm`^GejQMBA3hHcsr`&DLp3v#tVk8BRGO@1yXhSPvywtx zqHfS8QK`ZIa(d(HO%Q_DlVz=tQt|5Y`+}46ynd|-vw)=C7m4df4M*_mrqiXd?amEP zmYl_`+!_Dv?yuLU^k|se#Htx)V(CtS%JJo`z!e8_jr8_zY2m80$!&UHh1Ffu&%G84 zRq5x!rfn3>C6z1W>7>WKV^<{dJ3f@nMCD**`IjLd&IXolqj5!0tK+9*WAzIzk2;dJ z(`@<6S-JMCH(vr)QX3P#Ryw#T91T>oUXFGA0M})=-JcbtGoM497_TM-D@UUMvXa6iwzUDXM z(ck}?%{TQAjVL9PO`9+|J}k=QPg?_d=GPc95UMF1moL2Hn=Eu{hB>a3OpY}JbHn>i z`)9Z7Z-~(kzl2YXpPd+vkk^{lnnKPazI*xZzFH=y7=FTB4Kb2EyC*Pd zs{{CD+aPQa2w-{el_}fG8S;zR{(tR2jHvwl)o;bP>zh-MCl=xltAEdfb&b^GY7!$3ha6j_J&d3AqKn5_*I<8yzS^Lq+ah)7c zxepDwI>J;tgcxZAh%6B(^sArq_H!TBV#Qwg)m!9&V68=uf=a)H#Yzsn+D6>QKS6Xw3^-Ih}4~Li`y_q#MCJD(m8s-wRT|ef> z7~M-=m{HSqsJXW$AfcSX_w8vveqtCZ$M2kqbye$qt;xrlojQsabOj-QbVX;4hb8lz z2P}r_L9_39gGquF3?XjWm<;2_>C2a_Far}S()f&u7zDdv5fbGyy1brLIFK|j=g^u>5$>QLrkL9-BaH3{KO|Wy|iPyanh$W$VnuNT|C?= zcLVG5$MckoDR*`3&hE_oqqW8q#_N*1(~0vlU}$fV?_nk5j7VnS%~`wKs(ZNM|2;i& zV3sD7ole6l2zp=c-Md6oQ5;6HJ9|OVp=GDl;+Br>auLnVeA3;>a1}^Ajo@Pwn=oOY zsg+<(ed(1@LDweI6Cd%!Qf}H*v|cvcd^dnZOCq^QnPMTZz&B5@;(+c2tSP&^GkrbpuLL?=Zt_4)2*_$pwTsoGA6i`|kBwQ4bPHC5x z4h1FT;kVB@?>pyw&Ro}bzSqn(|INgUmx{px3^g?Y0sudl-~i2kGrSNDQwB#IkqBP^ zAeh4|9RL8yeS+jRFrf9>B%d6^Yjh0f6`bFHt&(T?D%#Uf^rV-r$!> z{|{PMuOJt^mJI;pTmV1?Xa^^0yE!AA0N_?D-Xn=eM)X*h$_+090C;O3FL2|5lSD#` z-H?8P005)J=jwP&G|g}RdiglwjW8p8F7h9o)I2UoL?HfPSSUW{{tp6K>RfLG(iyK| z@R#w!H##zs&(7iN^8z0;(&9ZCc>4M+m&|>iJL78^EAV+RkoK5B8}iyIEssDB^r)o_ z4#XUwt*9eaC^g0g^k~)7HE7qd)^F@;>bj$e-0o^DN9SYO+^75mI#mdqMn}H#DCr1t z+Uk2Mm00e{1&mq!-X9QcKyYrkzq7k5UUlw_)BYZ|)z0axP>^x;ia*2fUD)9#1^L&f z80L?Eu-nP}H*PQA8p?R|MdK}_i1b$dKg@!B$DG6v6=5^}_3s~@{^KQYx zuYBdv*Xy#sGYv@fi`J?6g z1QLbH45a-`P3tQb#-$HZ^xq3Kb}v*<%EtKueV+d%f9iO%9~yAf-`N%N9rx=m zYD|rphb>@d=oATJ;~y=Rng|PWN4CNV#4S=I=$he1x&veybcCv~mNhe*NRffm_!~s5 z9{CT5<{<3}^Ep?=WB z{k-gmi7$Clt~k3`Xk5)Bia0L~Acfq7F57VW5kDh^6NPYyzo>zES*`SbdmkmE3aESE zi97~F0q<~v<%>rSN1wUW;*Q?l9Ajs9r9SLBz6Q4**D-mmw4L0}EaXBn@9=jvX~n~J z_0(bNOSBHU+YmZhM8rrcaci`ro@`^Ml7YKkNZt%P6^rDh)By}zMgt!bTeIa!I?Rxi zF(4dKWjW~KjC5eSko;Vd{S|7Lzq*ob?|Bf+i>|iD)MB_~kG6yJOqmt9M5#4bqjWbX z!4)IBhp(KQu%mkQ7Nv~B+XuPM448~^=@Adl+F`pY4DRsm?q7ZzN8yPg)pX8x)>YOE zmg5WSPiIG&9HY<`tfI%8L|SWz=!wn~UMnXudV!JBjM4!=WVeb^QY(z1)H$(vTw9?-Dn-qi&QC^ zV#j-#1kQXMb0$8aa%-YloqyYAW$mM7b8 zQX#1HTZg({hft@%pLr}Ret`21)9au$`UG+8<)h!n-rY)2PenV)GfVy@=+^W2%A!F{ zV!K+37UH=lsH-JAW3wCeJb)d`Q@5(&#sAWN$hy+Yh_;pM72-}PQ()HnY;-i|PS-q7z@$goB$ z*-jyF&DZ|Ru2`K#ey#N`=^#}AwqaMYY5llD4+C3y<~)<;i!<^q>*{kIKcOo+iCfRz z>C-Nx++@}qb^*_t`(Ick6RkF%B2ScRgBSNYt7Y<9HrI=pHggk95ND<@qMOy!Dwj(4UtJSn%^oQ$jJCNQm>$}oJZtI(&P{d2>Nh@Xjk;lJOPwro*-qxFm*VqgQm7fN=TO{(OC zB<39M$LQzN#Yh%xWvFlPIg?78$%dBbwQ{Ml5flu0kGN8_sTMcsY~-->QIF)3?tE16iXB3*^y;Y7Y&^s&f+%ktxc+O1sm|E*okkc{^}{>r8_KhJ+@6B%e~G{^?lJmL8?sJ=RgcE^0>Zj@ylQ*czsLD` z+eO~?3med~cMMxM^`~`QR7=YJJ+Gx2q1l#T**iG(V5MnxBdxppSDf6= z)qQ2uFKPC`%WqE%m)nPYlCv*q!U#PTxpsK^L48(D@T}d(uh_+rA$l65ZwZf9(ys$K z8P%!p7k|v^4SUn8G-*BLCH8r8=cX1@)h7ALddWMj)IyA7P10rF4kEH>Q~`OI{FOBn z(Y!>mE%C}cKq={9E@L=d2&5N)B3TtFqLvF?$&n;ipKneMi;c&-*Z; zN49%QEmAiSvFJX~=!^g7S7zAS;Es>rd+IXbTrP_3gh--xGhZ@jY7M64{d!)|x>$$G zjp_#{t!hwDU|Cf{>3$A@jeR7A2IOL)%*U=buL~ExgBt_-_7`dg^eZjcc?MRloex`0^mZ_OD7Vcoj3X z$hKcOo@Su6kwa5^SXAQYYWk+panaqVV$^TbN9f;yW9yl;?Usxi$U9qE3Lp^UXzyfi z4>)9|E#F_iKz2zUOLYT@;)+!EGEoyZH~)?~N!U{pKbK?9Rw)Mbeozzv|6fCWS@5N& zr}*aP=w?iWm{Nrf#to7K8JEPu_)SrtdU_foBO>I8j_}=tzc*Q5S+&RTy#U@zG)edeI{vQ!*aQCny9A^Ju>^+@ zF-QdzfXYBW5(0!SgiAy+MBYSih}nt50Rp_20sm=8Lol#|9$0Z$9C(70i9veREpB=U zI+{{^CyiVg&pXhJZ7KP-VCKQ+4hv20?B#U1TkkqQ#@cW6Eposca>NwiC&E+?%&ae%-Mt#7XzGW|fhvid&Z{iSfKq#8b`&!2&T+=6shqVW&Ls40`QIE*mlo1SJAA;EpwL7HNmh>7eTVAH#<9!=cmbEy4 zv@GQnn5*a6>KUMykJEJsG*SD&)zL(hY-VLg?_$!HcrRuA!Ip>O3gc6T4RsdUAdtw`q%_PpoWS^}U=V?9Z?) zOpUFHKaoKOn`!#Po1Wx3_hJzC1vA^jT5@|=xk-+=9E%PWfyI?Gw>yUD8T#OvnEAbd tAeT|orv|6#C1V diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index d2c246d1aa701c85f3438d4d3baa7e3a245aff90..7efdfbdbc78248f820089a97017c306e49c23955 100644 GIT binary patch literal 5068 zcmV;-6Ep00Pew8T0RR91029mr4*&oF041~l026Zn0RR9100000000000000000000 z0000SR0d!Gg&+tH37iZO2nw74mv9R%00A}vBm-CkAO(d@2Z2rufgBrdA|-5Gg$>Sw zf`5nC2HF3ANZ@3MfP1KVGYAZU)vGd94YW1Xz$W4~4r5p$Ai{K|3$xzTeGzLoj)RT1 zCf|?7{*IH*%1-<1=f}xMq~T{qNADT=^6@=-qd|jBI!%ZjswTw#kr9&2LdDyBtDc$n zc2}Pa%n^{jj|{*%05HkX5ru#lUJ?OsYRGk{)9xf$29k^kn*&af)jl9@18ocN_UrN% z*|}4950wBSTmZoHX*0Q~F08;;et@(AIzX8c_BkX4rSqPP+C`b!u6o+z0lHZjgxmkp z;A&E92vhpzYNA_%-1d-rkjR8vTH;5%Z~8i!(yH!+oCLpNqO?b8#r`3;!!ojsz&4oN zUocIFh1Q|Q+ZO{=5I*TA%ja>Z<*<2$Dl63`%yc zr+T{6Ro#Q1`p;s|EX@Dh6T*Rn6`)Y4{#i*hyF42JP_cx^$Hv9D2pJ=M7Tv6Pm~^Ru z2fa5xI{!862BmXcZT_q;{38+2U;!(!=K2jg=)L*q-M2D|000Edo9pHqT1P3X6@maL zvzhj&end}`z#g<8fX9R1Uw^Pr1dQbL{_-uS`cLr73oe^g@ z(W~e2`bi?d>O6{75xf;{jsb~k`7z#edfDuBs4cvn))liTdPhX>#Q6Vt7?@btIJkKD z2vQ&viGi}h)NU52n;ELw^iU#EOH9-f7quirElHx5WKl~BQA-WN+Mq9|Q~aIQkV%CQb8LxqZFV3X1H`Ms{DK z6cFc{sdQKDBerT*sp#JXnMm`g3}orFG8x=hGqz3JQHt^0VIYpfF>o7zv#Pdp-$*v9 z9i8PgGL(#br+N|Dsl6%FNY3HK5U^) z$7H-M)4U2`K0~Sr*aS2Bs>6gZdOMHfQzn%}E~9cpcc*UQU=~_)NZ9iatkIBNZ$zP! zz_BqYK$s`#HD!@n(nXlp#yHtLN?H-r55sQ!kH!sKpn3pkS|U$zbMgo7fV3YBgEea7 z1MBcnNZSGLufz@kUn7FgccD{l$b3Ys;1(iwNPr`ewsOj(*^~vzB+YBB_AY7L0Pnws z!oIul7H&(YD+)L6hWB6U1!KJxo-sm!vCcO?$cG1uN2Ul6;=cGLh&_Q*Do`JWBD-3c z`{t9T8H-h`cf3Vta70q*Y)p*!93Xg#XrQT9^moJpvZP&*z*dVPG(WZ&GljN;uYth4 z^zh8%8j!uDlg`YnjLt00c<+U$=;XjsqMpR(G=DQX5|l(3I=fQJkUES|mr?35Mt!Ey zfN>f!okq-{F*Dck*A}Y!J;L+*uR)~(KMs^kgxP)wniK{1C`mLcoLN-N1kCrd}CK}VlT=l(lVWa_ZOFO|SdReOKapbx)x~wkE?Eq6eD*MVC`6`i zOCX^XIQDFK>4pbz|ZDK36H~3{#FBPSQ%YR~Nbum8`2ZUfl>w8HOxypFQO5 zZA)5%O)cHsQO3+Yd(u^k_gZ%MoLQcfJ^cSejy> zGsu{R!DJW<50%*pkTVT~3#xcHAb_ZSu%KW%hS0)9T?Q62v4n}GOeV_?GRx7=3iPuQ z{iec0V>PZu<7`lCA?UCK$?BqM^&nY&G_3)e)(}l=gr+q{b9{nAr@dghQh;-O2Hwmi z?i3*SY%>I2vJkMA0<`L6v;Dt{D*Obolh9 z4MWah>1=Qogrk>_SawrK7z%W75irZ3Ln$H#*!J|ampa3&V?T<%swxHu({BtdiokRx z&rQ2$K;nu;`-(NvQET|3^`f?k6v0oQYZ@-_Yu9eLU-Y0bgUA4I6TmNBKQeDpLUvH% zmi@7Cfk=htrQWu*yL#CwtpjSlw?$kj0O+Q0de~FrXZRNB$2J~VZ9(R5dutbl^g3#| zvQFc3eyc^z{vy%!!UWxi_=!=YO5YN)9s+tk5YflD4dl{x=TkzSq}=J^c%bz?zAe9j6o3yau1$fgSj zSqK#3h3{n+WkePP#k+XG6dP8QVrbf+uvPua37f(u9Y-f?g zE9I%1O@*IDX7`t{`Bm9;;ZP}`%cTE*{$2d@vE|< znQW|Sd{ZGGB_kU%xQg(1+s8q?9>YLv#6g&=svCfC)ll`@y%2GKLpOwzCH}*t--MP* zg=Ch^(*?u_dmlln0}TQ9RZLB6m986AepWaV}9_(ayI2J2~B2{>j>I zEh@;*l1cSPj?{vZg33xj!S{`cs~^5&Nl19b$kWhjL|Z~wD{R8D`3?#!k5$~W-|8GX z%IOPV>W1r*>#E-MS=W7*Z`?6cnO&)z{~RCpgLrkh=x~T|B;z$M@dI`MD+k7b%cel&7=^+gm_( z47dh#B+p<6$w$%+wvZ<5LG~Wpts%d8r&$6DOnXd=u@1w2zU{WOT+WksqBsQbv?*mS zMd&ft$ijIr& zeJgG4RgCK^KMu2WF3_hygD(+=ri5I8!FXVV9&|`oZv?Cj`|k9?*56L=q7lW01NO5c zd;&8Fi*`DS%R+~;B<(P^OHF;fJvu#Za9U?7h}mnzw>qu0&18yiwE8ItQC_mDu%E(S zkbxX0i3;#$L~-=v7sV1qI7h73|_h zl1abBxN=;v4=Hm;hH~+}mb6HhFIZN`K<1&Wr3{XifYs6Fs^1#qX!ERj2X(NE57FNG zBcdFeVT~y#kt(gqXqbv*Z21Vi3_~PYRX|`&m{2JuxfFX5UBzCM;=lu=Si?Vl=)$qGW}TbE*O{Z&-{-~aue(LZJ7_eSr1eEu)~ zkBlZHf<3h?1m3 zpHo6xyyEXmOWc>=C$Tkg4HGZFR@nm<=5qQ)4k^(RqO5(m3|%a}ZQ7Wjhqp1N#MzYQ zqj_qjS?_<3jSiLsTms5D%nvr2f#*aav;o7~iWH>Wee$^9 zJG$xjXEV;nwBL2pqE_uePPt8r)CM=lP7^rcH;@ZM$xzz!@`LTI*|aM9y;$ljFL@Ex z{Sj1UMR~fftJkZBO0UXRl9LP1tsWIpvAe{D4l`-Lc|tZAbUFwVCTC@l55{rHXxQn0 zNxg0oM`0j^XJ&>g6+CIx5=UJVlIemAGxoZ`j$+u>0X}m1_I@f-=v8m3{M+0e8xizA zq28YEDvU8h3A5OM5m=li7jk89Auw?Rky7j?A{{10+B{G!w@uki#aNERFfjPYR&4-8 zCT<{?MRK?7EljfAO7Ta0{ajb;D3-y4IVK<5owz%w&r~Z9Ndp>JbW)(QH_6{gkqhP1 zm2wn09xY`QvZ6%*J0BlMlL)r1T|F$XJ9!dMLyM1Z)l4GsEX+0_+_aOX1FAp74U4$DlDmLOci+6rHwJHbw9;cew zbkjsoD9!tNdvc15`3CGsnJYy%Cg2`t&sUb`>+O0r?rO%^y{Tm#8K<3rOLK!^F)fjy zqLg1!G%$(f>aYy#duq>D)=L7e@~kN5Z4`%w`%d>yAbdO5ebTpO&+2IqZXS1Ha?_(E zJW@sk+szJd+&o%Mh@Om{F5iCd>S}t_j+*Ll=}4W;oYX_2MH-oeg3+j?zmPs~Qq#07 zygb*7Rx@M}%A$!uLDleey}ob|zw(#=H+-)F{^dWp9~`yww{t@*p!a|h18V0v&;erp z0bOp?F2S%Q3ii%NA3(e!Y7E2#I^V4;k+U86)R<30d+aRzO#5Zo-p*e;pqHIYRt)26 zV!)U%;)#uS^mfx*KZxI0p`~tNl_*)d$|zW&of1tQTx?jC=wgS?TpV82U^cB|cQKe} zaTFDBBX5FLVu1WrMz6+e=VD0*7e}iS!*oMuE>2{vstHX@HNt}ckOUp z9F6mf3YD9Sdivi3?#KRkbuVx|{??hBn61~ngrhbsv$ZHa{gmHc#slkpuA}QMRhQLU z#!ZrBIa0-l5-lA7$qfp?f*EQ|uOsAIKoZ4~VF9xDyi02HKifO#6sl7@*h=ENWV9taY1@J*4RS*uzvDqF-lWDYtux${@ zV35kta{)Z6s{zSy9lO#jDlk!qPA4K;jx#^DCR&ftyAlmP>0u&rg_5!gm8#TGm%7!X zUiE1JN(?Z>2xCkzh2*ypB+ro`xtnAP7m*-2V3=^kFySAPEuAv5rAtSq@B}g8qGG}~ z!!IV$-S?fuF#CEO({I1wRzH4Q%kRAFF1zif{5>B`_>!1#=Q%2H-8cN||M*ks$-U@t z**o9hEW`dRM2Vzuh=rRlYewyy?Yx?e;c}zo?YeP4OG@@zHsWJ==q(LzJ zL*CxMJfyZsB7wxQc1iGj+R}3XNP#c^RKC#$$bdRZ*k&mx`PNs|F6!;7rwv|2H`T zx3b$PQzdG=m4n;WTt&iq+A< zO#X6{cTj*pW;uZ?JsUfbS|n{Qqe_7Gh+E9=)neMr7$T)B3F zr@`n@m>7xpSq7s&!{s9nf(skaSJ(s$*L%kG0rFoSItC^dHV!Tx0z#G?c|duQ4}ysm zw`f2>r9uJm(T?~SM|`YA5F|n%5eA6}NJK#*1`=_QNPt8VBvK%e28j$v5O98cBK4#1 z6Qw_HHbzkw-p~CBQt%iCQRLk}OozNfz5g6o++g&YCel2l`5|>dZrYi6m4X9UL+c znCDv1xE|2VqnkI@Mo4z3vStL5TeQjxg7a#dwDUnF$kKuL?8W_4n)LtYR4TJWeOTNG zY2D<_7&O`_$ooK~QHh?8(>XL?u-l@gW6&SSFs}k^FGH&dq=sa)p2L7JI-AS!KNczp zoI>V^E=hgwTqas$8XWi;YcyajYDBgj!R1L(K$s^PG)0kG(n)M+V>;;uQd*TLerxx` zUmM47g7ODqiFt52H`o5tnZy8LJy@eQ&RBWDoh(LE zOQXMw7KE_qLkNz36Ct%17Nb*W5w-(?c^TlE$1#w-q`&pmDa_$2y!XOGbZwAQqS1;U z-Hqw!NKmj;MW_*hI#Fnl0!?DjA`Wd*p+g#UNrxU8^%Ye0Cq>Qgwu4Fq`P@;MSa}kW znJB*_Ph}32zeARyLs$~A8B%^%I9Nn9%uq2$#R3&eRP3YT02PO*I6}n3BIJysgQVLn_=6?gd1+D^q> zuW*S6;c*nuUXabw#G9Bo`C!`ZFrdr|5wVI7F8#q&WvGi40<>ker4)d&0Ob(%r$pqG! zBrg>RJryY)Fx)dd!fIGd0l84Teh^(KnbzpoRKp-50*{rtID4GARf35#hmgl8vd-An zI0-A6?S^>>l`N_?Mnwus5r#~t-mUWHvL&p+C0e?^p`@96bf?7?Z`7IDYMLhnL%*(s zN~J(%)7Kc*&1MX;7^^ZXQTJxl-N`r2`kMEGEj9Hn?}POe+l&WbBQ>e!_O+hPDwzkB z6|=A5umsDtX-B0`$=K%k=EiC2^A@ReHfrw?iaFT#aJifFe=)LPE)GA0ZM&$m#@b4m zjV!`(J@zVvh?9=#GOGnH$RhmXslBw#CtK{?=GC*T<=)twwV$IvnglDvxd3YcF3*PL zLzPN~O;Hq;yn-<&P;M8Gi&j)K%utZc0p=)~$72l@IDldfutYVM@l!Nbh>8vb)m&gL zkgT^zQG=i+K`lbIlfx?NphaD@sE3yH@K{SeA48A>3@IyO2`eeqYS7OG4gy(2zyXc| z!8jK<2{e6*B8T>@*+K$UD09^0LgJc-dUJvTK8vMFV5I`<3Kk1qCSH_@sWS1Z1GuTcT}3=#=EA44@TDw#D+@n5fUgStRGR*1m5bUL zN~3PHtd*}E)EXP3@1i#-yX%KX>XW$66cE141MQp`S!0}Lgbv*llnL#NEuzCj5ugA8 zEg%DHu&Ax*^~eD9gd)J0IBN~hb>vog9-d)n;?il)jHjosoVJUZ(`N=HGt0>gmY!;W zjc4j${iT?uO*4G{qT&9yJaFKU_^wmQI;JR)mco0U*~acsL9s#Mx9ft)1yLM%UhFN6 zzdV^b(-_L_+y#)-y6txG9yIe0#XBK87t7+eW!tL4@I$_}2SQfp*;2;%Bdf`!*J^A( zR!|&__!n6$`TiHabi^=o7u?6 zH)dt6p8x;(cjC{FeIFmyC4Kec^Zw5?|e zRZy7w_JMZ3}?1k+$1hBR7$sh_HLmjZugiIWe5iI6!zxj39mPDg!TMrrM{su<(rNcMg^1G%9F z0!cK{4I|--)P*hDSV44!aBDEdlS*SPN$GPAOW7?%0PwhoWO$6d{Mz$z-rKJ*d_0rg z+Wm!>HwaO^D}Zt+i9B;*v}R%E*BiiBYVsIxE6FJ2nBQgp zV=E}tRn@ts&ehJjsOPKCu@+<&!FiM5n&Ok<DngKnI zSp(wlnFM&x-l-;mR5$V@u~CnmmDG)yuJ03mp}9eb_MU{maWkzYhvP`W^vPY{!t z$iq_`pRuPWRyTJIc+5SjB8`($JBHtZybvbuj{ctypZ+WAq0@Vc*ldD38wulgT<1is zkPvnk$KS%2^NLo~Q`FbzD0zci0me@jACU#Zc&J&uI~Gok>65#9dt%%Mj-X$1tu&1k<$& zJLa}MK?>6*D2Ndhzz9&s3o@t5nB0km99<*9HAkav*bDv)GM1L3iR$VagB8LY;c;PJ zaD?5b6yXFT;}|QK3Y`el=pACP5eero7%rrzjT+L@9)UD4-<;puh1zSMH;%BSK_T}3 zAE9XfMSD%TWq|`l;`TVZrH;Pd9?c%nF|9cj7n_CN+&-qzEYwVA(e5}P1KW^ZY{}S zC<}zh>Oe}UzznGfH*=T33uSkl`P*tcCxH1T;hMQmtTtf*LSr~@M! zi_?FKaAY_l&!})qD&qV*bxEQBf+1;5L#YSSR#JqhEWD-$N9D#KO@nL0-LHw?JVOVY z4|*(ofenro7+7!3jKbA1@T?Ic6BiI@T?b=gF#@$XMlJq4mI=Bt3rifqF&TK~WnmUB zC=gp88^Xfxy-Oe^wvtFq5QPRcBvL#!;&iG09{%ANmH#;cnH7=YyF3lOjUx*SDAqJ< z+a#wHxUMnVB*lo**GDl*F{alv|Mrq%DRJ0xn_9eGsG=hO-o3xZe-#wo8Nc)Mp z&C1rS-uqt-Y*#`-&Y>fD&yVG09TI#|a)_7rd3RpLA*69vz3455&AuZ7)oFN(9-5zW z61#jd?@5Z?!(CBiO;pv?C!||?pQVM2PL*R$s+ce(hESk|C$gtaqP4wjO(HUEYs;}* z<=Ucmzs5!fYr-y|S+t-FiC!vf%K}%)uoMs2>Qk64)x3|xdnFvA9GI0g9L?J&=X#}x zAcLF-0Mh}_0pLDEt-}V<98g&(4L|7p@E(9G9)tgXRzGA5A9Ar2fi;6MCTSdt1{+-h zzUrMj(9JHvndTMDfIZ-{k(-s2o^#=p5cnE3Xl_-~kP!yoAa7EnUV|e}dGYWz@I&7= z3Ox@VW~sD3B6;Buryw{2_*&)#&CTjhi61Iyi;Nr*9wT5f=sO$+w*k>gXBVu^u(Q_noQ5pR-eHmT9Gv0=P5SFJZMW-nf+I_d zJZtxsV7F6my`yF$iK8&^h1(LFvMJP!N}8zTDI>*qBP6^k1T=-9Tj3F}XZJQm4GUa| z;=d-e6H(eC!G-y`=EB|*b@>GZhx|LTXiV!tK*Ru#@MpNk1mSV*i|J}mG-{&f_rfsH zbGwpO06gIXva2vtS`U$WR*G4l&5XA9qu39A6dAc+qUcDyoU)vXYJn0~rE^XQI|Sun zDvSBOF}>2?bP7dP4+q$}Moc;3xZRUU)~{ztJPOS`6c;w|i7W}}0)&YhDHi}755jF& z3x_Os-euWp4?^$)CVNM9DX=gYAxeB5bx;$=_H@@%li`9NYK+ zqm~E-oosPoKEo5T3)rxP1%j7A0-m%Q&rFZ!tNFCo;HFz*&6D;C-xau&gJBVa6qqE_ z3!KAGVm}Itz+`h(y?sE`2;~K$nAA!#4tIu*83^F{5@ z&j%rJDDU0TPW?a88Y9vUU-U5h5AYdmaHC7||G0B*jtT<+KNL`F&kt4sn|T9&i)&Ov zOcWk{56qy&I~0t-BseKi{{byNOv)?-A*NIx(U$3F`fh#vKe_;}wFG*^@E~vrP0Cvv zu(nH}-4N+68<4NhdMR|4dTBJaggH|WgoWxwFAo+E!VA@Ry#lGETt0|UH-p#)FmDX> zB8a4WQCxK>6sVdA235yioTLDuLY30XFz7V=Qx5BC{U2DGItQ zm7;urk6O=Q{R3ReY?3x`jW}=nCw8tTkmm3pIqcfN+c5IyCEay1S1uk+4LLga4 z4w8oyAVnbP%wT!kDJ)MWjEFDegtQqGq;;2w`ZFm+{aG9$KF0`YvusG~s$lw>%FUgr z$@aQFnC9}#%Z5UqF}Zmgnx5hP-V4&YR!Ez@<&ppze*6pVb%^@Yw@4mV&iy#3@+BZ; zH0sQ@y8(hq7u*d4m2X*N8}UIlzAVCL8r44P6nv<>Vx8mC3{Uz2v~S8kg|Fr9<2zVL S*`!;CpE=t5KRzi04o(09?GiZv diff --git a/src/consts.nim b/src/consts.nim index 38fd870..17827f2 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -113,7 +113,7 @@ const $2 "includeHasBirdwatchNotes": false, "includePromotedContent": false, - "withBirdwatchNotes": false, + "withBirdwatchNotes": true, "withVoice": false, "withV2Timeline": true }""".replace(" ", "").replace("\n", "") diff --git a/src/parser.nim b/src/parser.nim index 6b8da60..da0ffb4 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -6,6 +6,12 @@ import experimental/parser/unifiedcard proc parseGraphTweet(js: JsonNode): Tweet +proc parseCommunityNote(js: JsonNode): string = + let subtitle = js{"subtitle"} + result = subtitle{"text"}.getStr + with entities, subtitle{"entities"}: + result = expandBirdwatchEntities(result, entities) + proc parseUser(js: JsonNode; id=""): User = if js.isNull: return result = User( @@ -439,6 +445,9 @@ proc parseGraphTweet(js: JsonNode): Tweet = for id in ids: result.history.add parseBiggestInt(id.getStr) + with birdwatch, js{"birdwatch_pivot"}: + result.note = parseCommunityNote(birdwatch) + proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = for t in ? js{"content", "items"}: let entryId = t.getEntryId diff --git a/src/parserutils.nim b/src/parserutils.nim index 08b3cf8..98dd2ee 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -330,6 +330,26 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) +proc expandBirdwatchEntities*(text: string; entities: JsonNode): string = + let runes = text.toRunes + var replacements: seq[ReplaceSlice] + + for entity in entities: + let + fromIdx = entity{"from_index"}.getInt + toIdx = entity{"to_index"}.getInt + url = entity{"ref", "url"}.getStr + if url.len > 0: + replacements.add ReplaceSlice( + kind: rkUrl, + slice: fromIdx ..< toIdx, + url: url, + display: $runes[fromIdx ..< min(toIdx, runes.len)] + ) + + replacements.sort(cmp) + result = runes.replacedWith(replacements, 0 ..< runes.len) + proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = let url = if t.photos.len > 0: t.photos[0].url diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 149eadf..047caef 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -78,6 +78,9 @@ genPrefs: hideReplies(checkbox, false): "Hide tweet replies" + hideCommunityNotes(checkbox, false): + "Hide community notes" + squareAvatars(checkbox, false): "Square profile pictures" diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 28590e7..b9d64bc 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -254,3 +254,38 @@ pointer-events: all; } } + +.community-note { + background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + overflow: hidden; + pointer-events: all; + + &:hover { + background-color: var(--bg_panel); + border-color: var(--grey); + } +} + +.community-note-header { + background-color: var(--bg_hover); + font-weight: 700; + padding: 8px 10px; + padding-top: 6px; + display: flex; + align-items: center; + gap: 2px; + + .icon-container { + flex-shrink: 0; + color: var(--accent); + } +} + +.community-note-text { + white-space: pre-line; + padding: 10px 10px; + padding-top: 6px; +} diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss index 5575191..7441d11 100644 --- a/src/sass/tweet/card.scss +++ b/src/sass/tweet/card.scss @@ -1,119 +1,119 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .card { - margin: 5px 0; - pointer-events: all; - max-height: unset; + margin: 5px 0; + pointer-events: all; + max-height: unset; } .card-container { - border-radius: 10px; - border-width: 1px; - border-style: solid; - border-color: var(--dark_grey); - background-color: var(--bg_elements); - overflow: hidden; - color: inherit; - display: flex; - flex-direction: row; - text-decoration: none !important; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); + overflow: hidden; + color: inherit; + display: flex; + flex-direction: row; + text-decoration: none !important; - &:hover { - border-color: var(--grey); - } + &:hover { + border-color: var(--grey); + } - .attachments { - margin: 0; - border-radius: 0; - } + .attachments { + margin: 0; + border-radius: 0; + } } .card-content { - padding: 0.5em; + padding: 0.5em; } .card-title { - @include ellipsis; - white-space: unset; - font-weight: bold; - font-size: 1.1em; + @include ellipsis; + white-space: unset; + font-weight: bold; + font-size: 1.1em; } .card-description { - margin: 0.3em 0; - white-space: pre-wrap; + margin: 0.3em 0; + white-space: pre-wrap; } .card-destination { - @include ellipsis; - color: var(--grey); - display: block; + @include ellipsis; + color: var(--grey); + display: block; } .card-content-container { - color: unset; - overflow: auto; - &:hover { - text-decoration: none; - } + color: unset; + overflow: auto; + + &:hover { + text-decoration: none; + } } .card-image-container { - width: 98px; - flex-shrink: 0; - position: relative; - overflow: hidden; - &:before { - content: ""; - display: block; - padding-top: 100%; - } + width: 98px; + flex-shrink: 0; + position: relative; + overflow: hidden; + + &:before { + content: ""; + display: block; + padding-top: 100%; + } } .card-image { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background-color: var(--bg_overlays); + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: var(--bg_overlays); - img { - width: 100%; - height: 100%; - max-height: 400px; - display: block; - object-fit: cover; - } + img { + width: 100%; + height: 100%; + max-height: 400px; + display: block; + object-fit: cover; + } } .card-overlay { - @include play-button; - opacity: 0.8; - display: flex; - justify-content: center; - align-items: center; + @include play-button; + opacity: 0.8; + display: flex; + justify-content: center; + align-items: center; } .large { - .card-container { - display: block; - } + .card-container { + display: block; + } - .card-image-container { - width: unset; + .card-image-container { + width: unset; - &:before { - display: none; - } + &:before { + display: none; } + } - .card-image { - position: unset; - border-style: solid; - border-color: var(--dark_grey); - border-width: 0; - border-bottom-width: 1px; - } + .card-image { + position: unset; + border-style: solid; + border-color: var(--dark_grey); + border-width: 0; + border-bottom-width: 1px; + } } diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index 64d7e13..2f3122a 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -19,31 +19,49 @@ } .tweet-name-row { - padding: 6px 8px; - margin-top: 1px; + padding: 8px 10px 6px 10px; } .quote-text { overflow: hidden; white-space: pre-wrap; word-wrap: break-word; - padding: 0px 8px 8px 8px; + padding: 10px; + padding-top: 0; } .show-thread { - padding: 0px 8px 6px 8px; + padding: 0px 10px 6px 10px; margin-top: -6px; } .quote-latest { - padding: 0px 8px 6px 8px; + padding: 0px 10px 6px 10px; color: var(--grey); } .replying-to { - padding: 0px 8px; + padding: 0px 10px; + padding-bottom: 4px; margin: unset; } + + .community-note { + background-color: var(--bg_panel); + border: unset; + border-top: solid 1px var(--dark_grey); + border-radius: unset; + margin-top: 0; + + &:hover { + border-top-color: var(--grey); + } + + .community-note-header { + background-color: var(--bg_panel); + padding-bottom: 0; + } + } } .unavailable-quote { diff --git a/src/types.nim b/src/types.nim index 996f572..56bc2b2 100644 --- a/src/types.nim +++ b/src/types.nim @@ -223,6 +223,7 @@ type video*: Option[Video] photos*: seq[Photo] history*: seq[int64] + note*: string Tweets* = seq[Tweet] diff --git a/src/views/general.nim b/src/views/general.nim index d002777..ac087c1 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=27") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=28") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: diff --git a/src/views/tweet.nim b/src/views/tweet.nim index f79c571..b0578c1 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -226,6 +226,14 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = elif quote.gif.isSome: renderGif(quote.gif.get(), prefs) +proc renderCommunityNote(note: string; prefs: Prefs): VNode = + buildHtml(tdiv(class="community-note")): + tdiv(class="community-note-header"): + icon "group" + span: text "Community note" + tdiv(class="community-note-text", dir="auto"): + verbatim replaceUrls(note, prefs) + proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if not quote.available: return buildHtml(tdiv(class="quote unavailable")): @@ -261,6 +269,9 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: renderQuoteMedia(quote, prefs, path) + if quote.note.len > 0 and not prefs.hideCommunityNotes: + renderCommunityNote(quote.note, prefs) + if quote.hasThread: a(class="show-thread", href=getLink(quote)): text "Show this thread" @@ -346,6 +357,9 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.quote.isSome: renderQuote(tweet.quote.get(), prefs, path) + if tweet.note.len > 0 and not prefs.hideCommunityNotes: + renderCommunityNote(tweet.note, prefs) + let hasEdits = tweet.history.len > 1 isLatest = hasEdits and tweet.id == max(tweet.history) From 61b6748d976a7725e6cc5d8292bbe07eaa33ce0c Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 19 Feb 2026 02:03:10 +0100 Subject: [PATCH 196/309] Add community notes to RSS --- src/views/rss.nimf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 6070238..2c71591 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -74,6 +74,9 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} # end if #end if +#if tweet.note.len > 0 and not prefs.hideCommunityNotes: +

Community note: ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}

+#end if #if tweet.quote.isSome and get(tweet.quote).available: # let quoteTweet = get(tweet.quote) # let quoteLink = urlPrefix & getLink(quoteTweet) From 95a9ee8dc56d3ec7030d7e8426cc878a8e426c57 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 19 Feb 2026 06:56:20 +0100 Subject: [PATCH 197/309] Update and speed up GitHub workflows (#1368) * Update actions and switch to GitHub runners * Bump workflow Python version to 3.14 * Reuse nitter build for integration test * Add missing libpcre3 installation to workflow * Consolidate workflow runtime deps install * Make nitter binary executable * Run nimble md and scss simultaneously in workflow * Run tests with 4 workers in workflow * Rerun failing integration tests * Bump integration test workers to 5 * Improve python dep install and run less workers * Use native GitHub Actions Redis service * Lower integration test workers to 2 * Switch to poetry to cache venv * Ensure poetry is installed before setup-python * Fix poetry sync command * Switch back to 3 workers * Cache poetry install * WIP * WIP * Fix poetry/pipx caching * Speed up integration test significantly * WIP * Cleanup --- .github/workflows/build-docker.yml | 22 +- .github/workflows/run-tests.yml | 102 +- nitter.nimble | 4 +- tests/poetry.lock | 1716 ++++++++++++++++++++++++++++ tests/poetry.toml | 2 + tests/pyproject.toml | 8 + tests/requirements.txt | 2 +- 7 files changed, 1808 insertions(+), 48 deletions(-) create mode 100644 tests/poetry.lock create mode 100644 tests/poetry.toml create mode 100644 tests/pyproject.toml diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index c9f0392..7a46257 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -11,20 +11,19 @@ jobs: tests: uses: ./.github/workflows/run-tests.yml secrets: inherit + build-docker-amd64: needs: [tests] - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 + - uses: actions/checkout@v6 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: latest - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -36,20 +35,19 @@ jobs: platforms: linux/amd64 push: true tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }} + build-docker-arm64: needs: [tests] - runs-on: buildjet-2vcpu-ubuntu-2204-arm + runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 + - uses: actions/checkout@v6 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: latest - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f4639a4..46ec965 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,19 +20,17 @@ defaults: jobs: build-test: name: Build and test - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-24.04 strategy: matrix: nim: ["2.0.x", "2.2.x", "devel"] steps: - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 + uses: actions/checkout@v6 - name: Cache Nimble Dependencies id: cache-nimble - uses: buildjet/cache@v4 + uses: actions/cache@v5 with: path: ~/.nimble key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} @@ -47,62 +45,100 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Build Project - run: nimble build -d:release -Y + run: nimble build -Y + + - name: Upload 2.2.x build artifact + if: matrix.nim == '2.2.x' + uses: actions/upload-artifact@v6 + with: + name: nitter-linux-nim-2.2.x-${{ github.sha }} + path: | + ./nitter + if-no-files-found: error integration-test: needs: [build-test] name: Integration test - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-24.04 + + services: + redis: + image: redis:7 + ports: + - 6379:6379 + steps: + - name: Install runtime deps + run: | + sudo apt-get install -y --no-install-recommends libsass-dev libpcre3 + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Cache pipx (poetry) + uses: actions/cache@v5 with: - fetch-depth: 0 + path: | + ~/.local/pipx + ~/.local/bin + key: pipx-poetry-${{ runner.os }} + + - name: Install poetry + env: + PIPX_HOME: ~/.local/pipx + PIPX_BIN_DIR: ~/.local/bin + run: command -v poetry >/dev/null 2>&1 || pipx install poetry + + - name: Setup Python (3.14) with Poetry cache + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: poetry + cache-dependency-path: tests/poetry.lock + + - name: Install Python deps + working-directory: tests + run: poetry sync - name: Cache Nimble Dependencies - id: cache-nimble - uses: buildjet/cache@v4 + uses: actions/cache@v5 with: path: ~/.nimble - key: devel-nimble-v2-${{ hashFiles('*.nimble') }} + key: 2.2.x-nimble-v2-${{ hashFiles('*.nimble') }} restore-keys: | - devel-nimble-v2- - - - name: Setup Python (3.10) with pip cache - uses: buildjet/setup-python@v4 - with: - python-version: "3.10" - cache: pip + 2.2.x-nimble-v2- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 with: - nim-version: devel + nim-version: 2.2.x use-nightlies: true repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build Project - run: nimble build -d:release -Y + - name: Download 2.2.x build artifact + uses: actions/download-artifact@v4 + with: + name: nitter-linux-nim-2.2.x-${{ github.sha }} + path: . - - name: Install SeleniumBase and Chromedriver - run: | - pip install seleniumbase - seleniumbase install chromedriver - - - name: Start Redis Service - uses: supercharge/redis-github-action@1.5.0 + - name: Make nitter binary executable + run: chmod +x ./nitter - name: Prepare Nitter Environment run: | - sudo apt-get update && sudo apt-get install -y libsass-dev cp nitter.example.conf nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf - nimble md - nimble scss + + # Run both Nimble tasks concurrently + nim r tools/rendermd.nim & + nim r tools/gencss.nim & + wait + echo '${{ secrets.SESSIONS }}' | head -n1 echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl - name: Run Tests run: | ./nitter & - pytest -n1 tests + cd tests + poetry run pytest -n3 --reruns=3 --rs . diff --git a/nitter.nimble b/nitter.nimble index 7ff8196..c206105 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -28,7 +28,7 @@ requires "oauth#b8c163b" # Tasks task scss, "Generate css": - exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss" + exec "nim r --hint[Processing]:off tools/gencss" task md, "Render md": - exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd" + exec "nim r --hint[Processing]:off tools/rendermd" diff --git a/tests/poetry.lock b/tests/poetry.lock new file mode 100644 index 0000000..d13bfe0 --- /dev/null +++ b/tests/poetry.lock @@ -0,0 +1,1716 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "async-generator" +version = "1.10" +description = "Async generators and context managers for Python 3.5+" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, + {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, +] + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, + {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, +] + +[package.dependencies] +soupsieve = ">=1.6.1" +typing-extensions = ">=4.0.0" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "behave" +version = "1.2.6" +description = "behave is behaviour-driven development, Python style" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"}, + {file = "behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86"}, +] + +[package.dependencies] +parse = ">=1.8.2" +parse-type = ">=0.4.2" +six = ">=1.11" + +[package.extras] +develop = ["coverage", "invoke (>=0.21.0)", "modernize (>=0.5)", "path.py (>=8.1.2)", "pathlib", "pycmd", "pylint", "pytest (>=3.0)", "pytest-cov", "tox"] +docs = ["sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6)"] + +[[package]] +name = "certifi" +version = "2026.1.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "os_name == \"nt\" and implementation_name != \"pypy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cssselect" +version = "1.4.0" +description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8"}, + {file = "cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "execnet" +version = "2.1.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + +[[package]] +name = "fasteners" +version = "0.20" +description = "A python package that provides useful locks" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "fasteners-0.20-py3-none-any.whl", hash = "sha256:9422c40d1e350e4259f509fb2e608d6bc43c0136f79a00db1b49046029d0b3b7"}, + {file = "fasteners-0.20.tar.gz", hash = "sha256:55dce8792a41b56f727ba6e123fcaee77fd87e638a6863cec00007bfea84c8d8"}, +] + +[[package]] +name = "filelock" +version = "3.24.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d"}, + {file = "filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa"}, +] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mouseinfo" +version = "0.1.3" +description = "An application to display XY position and RGB color information for the pixel currently under the mouse. Works on Python 2 and 3." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "MouseInfo-0.1.3.tar.gz", hash = "sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7"}, +] + +[package.dependencies] +pyperclip = "*" +python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""} + +[[package]] +name = "mycdp" +version = "1.3.2" +description = "Autogenerated CDP utilities for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mycdp-1.3.2-py3-none-any.whl", hash = "sha256:d097b12a494223b89a666c87915d8acd48d08b92770ff9f5955ab764676790a0"}, + {file = "mycdp-1.3.2.tar.gz", hash = "sha256:945c405eb35d9759bd24c3676b4633124fac222ac132f735e9d2d812b49f1b3d"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +description = "Capture the outcome of Python function calls." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, + {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, +] + +[package.dependencies] +attrs = ">=19.2.0" + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "parameterized" +version = "0.9.0" +description = "Parameterized testing with any Python test framework" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, + {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, +] + +[package.extras] +dev = ["jinja2"] + +[[package]] +name = "parse" +version = "1.21.1" +description = "parse() is the opposite of format()" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "parse-1.21.1-py2.py3-none-any.whl", hash = "sha256:55339ca698019815df3b8e8b550e5933933527e623b0cdf1ca2f404da35ffb47"}, + {file = "parse-1.21.1.tar.gz", hash = "sha256:825e1a88e9d9fb481b8d2ca709c6195558b6eaa97c559ad3a9a20aa2d12815a3"}, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +description = "Simplifies to build parse types based on the parse module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,>=2.7" +groups = ["main"] +files = [ + {file = "parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c"}, + {file = "parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2"}, +] + +[package.dependencies] +parse = {version = ">=1.18.0", markers = "python_version >= \"3.0\""} +six = ">=1.15" + +[package.extras] +develop = ["build (>=0.5.1)", "coverage (>=4.4)", "pylint", "pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-cov", "pytest-html (>=1.19.0)", "ruff ; python_version >= \"3.7\"", "setuptools", "setuptools-scm", "tox (>=2.8,<4.0)", "twine (>=1.13.0)", "virtualenv (<20.22.0) ; python_version <= \"3.6\"", "virtualenv (>=20.0.0) ; python_version > \"3.6\"", "wheel"] +docs = ["Sphinx (>=1.6)", "sphinx_bootstrap_theme (>=0.6.0)"] +testing = ["pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0)"] + +[[package]] +name = "pdbp" +version = "1.8.2" +description = "pdbp (Pdb+): A drop-in replacement for pdb and pdbpp." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8"}, + {file = "pdbp-1.8.2.tar.gz", hash = "sha256:367c25c17555d3ac1f024b9ad494ff50e6e20f6494a84741487f3e6596d88f94"}, +] + +[package.dependencies] +colorama = {version = ">=0.4.6", markers = "platform_system == \"Windows\""} +pygments = ">=2.19.2" +tabcompleter = ">=1.4.0" + +[[package]] +name = "pip" +version = "26.0.1" +description = "The PyPA recommended tool for installing Python packages." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b"}, + {file = "pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8"}, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"}, + {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pyautogui" +version = "0.9.54" +description = "PyAutoGUI lets Python control the mouse and keyboard, and other GUI automation tasks. For Windows, macOS, and Linux, on Python 3 and 2." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "PyAutoGUI-0.9.54.tar.gz", hash = "sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2"}, +] + +[package.dependencies] +mouseinfo = "*" +pygetwindow = ">=0.0.5" +pymsgbox = "*" +pyscreeze = ">=0.1.21" +python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""} +pytweening = ">=1.0.4" + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "os_name == \"nt\" and implementation_name != \"pypy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + +[[package]] +name = "pygetwindow" +version = "0.0.9" +description = "A simple, cross-platform module for obtaining GUI information on application's windows." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "PyGetWindow-0.0.9.tar.gz", hash = "sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688"}, +] + +[package.dependencies] +pyrect = "*" + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pymsgbox" +version = "2.0.1" +description = "A simple, cross-platform, pure Python module for JavaScript-like message boxes." +optional = false +python-versions = ">=3.4" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "pymsgbox-2.0.1-py3-none-any.whl", hash = "sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b"}, + {file = "pymsgbox-2.0.1.tar.gz", hash = "sha256:98d055c49a511dcc10fa08c3043e7102d468f5e4b3a83c6d3c61df722c7d798d"}, +] + +[[package]] +name = "pynose" +version = "1.5.5" +description = "pynose fixes nose to extend unittest and make testing easier" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pynose-1.5.5-py3-none-any.whl", hash = "sha256:673751d53fcfc79b1e48c14f36c7a24779ad43676eeb85736934de6a2b3d8ec8"}, + {file = "pynose-1.5.5.tar.gz", hash = "sha256:81da4e26473f98dd37497248eef4352d3221d1d56edf874a00c6bdda6daf7f49"}, +] + +[[package]] +name = "pyotp" +version = "2.9.0" +description = "Python One Time Password Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, + {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, +] + +[package.extras] +test = ["coverage", "mypy", "ruff", "wheel"] + +[[package]] +name = "pyperclip" +version = "1.11.0" +description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273"}, + {file = "pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6"}, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +description = "A python implementation of GNU readline." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, + {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, +] + +[package.extras] +dev = ["build", "flake8", "mypy", "pytest", "twine"] + +[[package]] +name = "pyrect" +version = "0.2.0" +description = "PyRect is a simple module with a Rect class for Pygame-like rectangular areas." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "PyRect-0.2.0.tar.gz", hash = "sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78"}, +] + +[[package]] +name = "pyscreeze" +version = "1.0.1" +description = "A simple, cross-platform screenshot module for Python 2 and 3." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "pyscreeze-1.0.1.tar.gz", hash = "sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be"}, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-html" +version = "4.0.2" +description = "pytest plugin for generating HTML reports" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pytest_html-4.0.2-py3-none-any.whl", hash = "sha256:907c3e68462df129d3ee96dee58bd63f70216b06421836b22fd3fd57ef314acb"}, + {file = "pytest_html-4.0.2.tar.gz", hash = "sha256:88682b9e8e51392472546a70a2139b27d6bc1834a4afd3e41da33c9d9f91e4a4"}, +] + +[package.dependencies] +jinja2 = ">=3.0.0" +pytest = ">=7.0.0" +pytest-metadata = ">=2.0.0" + +[package.extras] +docs = ["pip-tools (>=6.13.0)"] +test = ["assertpy (>=1.1)", "beautifulsoup4 (>=4.11.1)", "black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-mock (>=3.7.0)", "pytest-rerunfailures (>=11.1.2)", "pytest-xdist (>=2.4.0)", "selenium (>=4.3.0)", "tox (>=3.24.5)"] + +[[package]] +name = "pytest-metadata" +version = "3.1.1" +description = "pytest plugin for test session metadata" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b"}, + {file = "pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"] + +[[package]] +name = "pytest-ordering" +version = "0.6" +description = "pytest plugin to run your tests in a specific order" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6"}, + {file = "pytest_ordering-0.6-py2-none-any.whl", hash = "sha256:27fba3fc265f5d0f8597e7557885662c1bdc1969497cd58aff6ed21c3b617de2"}, + {file = "pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6"}, +] + +[package.dependencies] +pytest = "*" + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +description = "pytest plugin to re-run tests to eliminate flaky failures" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86"}, + {file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"}, +] + +[package.dependencies] +packaging = ">=17.1" +pytest = ">=7.4,<8.2.2 || >8.2.2" + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "python-xlib" +version = "0.33" +description = "Python X Library" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32"}, + {file = "python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398"}, +] + +[package.dependencies] +six = ">=1.10.0" + +[[package]] +name = "python3-xlib" +version = "0.15" +description = "Python3 X Library" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "python3-xlib-0.15.tar.gz", hash = "sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8"}, +] + +[[package]] +name = "pytweening" +version = "1.2.0" +description = "A collection of tweening (aka easing) functions." +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "14.3.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, + {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "sbvirtualdisplay" +version = "1.4.0" +description = "A customized pyvirtualdisplay for SeleniumBase." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "sbvirtualdisplay-1.4.0-py3-none-any.whl", hash = "sha256:516de155219aa342c4e090a3c5126cfe6b12416334bcba3255268e44a5e8a206"}, + {file = "sbvirtualdisplay-1.4.0.tar.gz", hash = "sha256:29a365b509cd7bfde4f758603b7b75703909b11cdf4245abc8f828ed35660d9b"}, +] + +[package.extras] +coverage = ["coverage (>=7.6.1) ; python_version < \"3.9\"", "coverage (>=7.6.9) ; python_version >= \"3.9\"", "pytest-cov (>=5.0.0) ; python_version < \"3.9\"", "pytest-cov (>=6.0.0) ; python_version >= \"3.9\""] +flake8 = ["flake8 (==5.0.4) ; python_version < \"3.9\"", "flake8 (==7.1.1) ; python_version >= \"3.9\"", "mccabe (==0.7.0)", "pycodestyle (==2.12.1) ; python_version >= \"3.9\"", "pycodestyle (==2.9.1) ; python_version < \"3.9\"", "pyflakes (==2.5.0) ; python_version < \"3.9\"", "pyflakes (==3.2.0) ; python_version >= \"3.9\""] + +[[package]] +name = "selenium" +version = "4.40.0" +description = "Official Python bindings for Selenium WebDriver" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "selenium-4.40.0-py3-none-any.whl", hash = "sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729"}, + {file = "selenium-4.40.0.tar.gz", hash = "sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c"}, +] + +[package.dependencies] +certifi = ">=2026.1.4" +trio = ">=0.31.0,<1.0" +trio-typing = ">=0.10.0" +trio-websocket = ">=0.12.2,<1.0" +types-certifi = ">=2021.10.8.3" +types-urllib3 = ">=1.26.25.14" +typing_extensions = ">=4.15.0,<5.0" +urllib3 = {version = ">=2.6.3,<3.0", extras = ["socks"]} +websocket-client = ">=1.8.0,<2.0" + +[[package]] +name = "seleniumbase" +version = "4.46.5" +description = "A complete web automation framework for end-to-end testing." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "seleniumbase-4.46.5-py3-none-any.whl", hash = "sha256:d87ca08ed642c2ed5ddb0be74259f570ecdc443e0a99ea0076abde0d013c8586"}, + {file = "seleniumbase-4.46.5.tar.gz", hash = "sha256:a1e22217874da901d361ce7577bf8d4eda990563d3dd2cfada612663380036c6"}, +] + +[package.dependencies] +attrs = ">=25.4.0" +beautifulsoup4 = ">=4.14.3,<4.15.0" +behave = "1.2.6" +certifi = ">=2026.1.4" +chardet = "5.2.0" +charset-normalizer = ">=3.4.4,<4" +colorama = ">=0.4.6" +cssselect = {version = ">=1.4.0,<2", markers = "python_version >= \"3.10\""} +exceptiongroup = ">=1.3.1" +execnet = {version = "2.1.2", markers = "python_version >= \"3.10\""} +fasteners = ">=0.20" +filelock = {version = ">=3.20.3", markers = "python_version >= \"3.10\""} +h11 = "0.16.0" +idna = ">=3.11" +iniconfig = {version = "2.3.0", markers = "python_version >= \"3.10\""} +Jinja2 = ">=3.1.6" +markdown-it-py = {version = "4.0.0", markers = "python_version >= \"3.10\""} +MarkupSafe = ">=3.0.3" +mdurl = "0.1.2" +mycdp = ">=1.3.2" +nest-asyncio = "1.6.0" +outcome = "1.3.0.post0" +packaging = ">=26.0" +parameterized = "0.9.0" +parse = ">=1.21.0" +parse-type = ">=0.6.6" +pdbp = ">=1.8.2" +pip = ">=26.0.1" +platformdirs = {version = ">=4.5.1", markers = "python_version >= \"3.10\""} +pluggy = "1.6.0" +PyAutoGUI = {version = ">=0.9.54", markers = "platform_system == \"Linux\""} +pygments = ">=2.19.2" +pynose = ">=1.5.5" +pyotp = "2.9.0" +pyreadline3 = {version = ">=3.5.4", markers = "platform_system == \"Windows\""} +pytest = {version = "9.0.2", markers = "python_version >= \"3.11\""} +pytest-html = "4.0.2" +pytest-metadata = "3.1.1" +pytest-ordering = "0.6" +pytest-rerunfailures = {version = "16.1", markers = "python_version >= \"3.10\""} +pytest-xdist = "3.8.0" +python-xlib = {version = "0.33", markers = "platform_system == \"Linux\""} +pyyaml = ">=6.0.3" +requests = ">=2.32.5,<2.33.0" +rich = ">=14.3.2,<15" +sbvirtualdisplay = ">=1.4.0" +selenium = {version = "4.40.0", markers = "python_version >= \"3.10\""} +setuptools = {version = ">=82.0.0", markers = "python_version >= \"3.10\""} +six = ">=1.17.0" +sniffio = "1.3.1" +sortedcontainers = "2.4.0" +soupsieve = ">=2.8.3,<2.9.0" +tabcompleter = ">=1.4.0" +trio = {version = ">=0.32.0,<1", markers = "python_version >= \"3.10\""} +trio-websocket = ">=0.12.2,<0.13.0" +typing-extensions = ">=4.15.0" +urllib3 = {version = ">=1.26.20,<3", markers = "python_version >= \"3.10\""} +websocket-client = ">=1.9.0,<1.10.0" +websockets = {version = ">=16.0", markers = "python_version >= \"3.10\""} +wheel = ">=0.46.3" +wsproto = {version = ">=1.3.2,<1.4.0", markers = "python_version >= \"3.10\""} + +[package.extras] +allure = ["allure-behave (>=2.13.5)", "allure-pytest (>=2.13.5)", "allure-python-commons (>=2.13.5)"] +coverage = ["coverage (>=7.10.7) ; python_version < \"3.10\"", "coverage (>=7.13.4) ; python_version >= \"3.10\"", "pytest-cov (>=7.0.0)"] +flake8 = ["flake8 (==7.3.0)", "mccabe (==0.7.0)", "pycodestyle (==2.14.0)", "pyflakes (==3.4.0)"] +ipdb = ["ipdb (==0.13.13)", "ipython (==7.34.0)"] +mss = ["mss (==10.1.0)"] +pdfminer = ["cffi (==2.0.0)", "cryptography (==46.0.5)", "pdfminer.six (==20251107) ; python_version < \"3.10\"", "pdfminer.six (==20260107) ; python_version >= \"3.10\"", "pycparser (==2.23) ; python_version < \"3.10\"", "pycparser (==3.0) ; python_version >= \"3.10\""] +pillow = ["Pillow (>=11.3.0) ; python_version < \"3.10\"", "Pillow (>=12.1.1) ; python_version >= \"3.10\""] +pip-system-certs = ["pip-system-certs (==4.0) ; platform_system == \"Windows\""] +playwright = ["playwright (>=1.58.0)"] +proxy = ["proxy.py (==2.4.3)"] +psutil = ["psutil (>=7.2.2)"] +pyautogui = ["PyAutoGUI (>=0.9.54) ; platform_system != \"Linux\""] +selenium-stealth = ["selenium-stealth (==1.0.6)"] +selenium-wire = ["Brotli (==1.1.0)", "blinker (==1.7.0)", "h2 (==4.1.0)", "hpack (==4.0.0)", "hyperframe (==6.0.1)", "kaitaistruct (==0.10)", "pyOpenSSL (>=24.2.1)", "pyasn1 (==0.6.1)", "pyparsing (>=3.1.4)", "selenium-wire (==5.1.0)", "zstandard (>=0.23.0)"] + +[[package]] +name = "setuptools" +version = "82.0.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0"}, + {file = "setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"}, + {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, +] + +[[package]] +name = "tabcompleter" +version = "1.4.0" +description = "tabcompleter --- Autocompletion in the Python console." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b"}, + {file = "tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "trio" +version = "0.33.0" +description = "A friendly Python library for async concurrency and I/O" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b"}, + {file = "trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"}, +] + +[package.dependencies] +attrs = ">=23.2.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +idna = "*" +outcome = "*" +sniffio = ">=1.3.0" +sortedcontainers = "*" + +[[package]] +name = "trio-typing" +version = "0.10.0" +description = "Static type checking support for Trio and related projects" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "trio-typing-0.10.0.tar.gz", hash = "sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3"}, + {file = "trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"}, +] + +[package.dependencies] +async-generator = "*" +importlib-metadata = "*" +mypy-extensions = ">=0.4.2" +packaging = "*" +trio = ">=0.16.0" +typing-extensions = ">=3.7.4" + +[package.extras] +mypy = ["mypy (>=1.0)"] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +description = "WebSocket library for Trio" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"}, + {file = "trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae"}, +] + +[package.dependencies] +outcome = ">=1.2.0" +trio = ">=0.11" +wsproto = ">=0.14" + +[[package]] +name = "types-certifi" +version = "2021.10.8.3" +description = "Typing stubs for certifi" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f"}, + {file = "types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"}, +] + +[[package]] +name = "types-urllib3" +version = "1.26.25.14" +description = "Typing stubs for urllib3" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f"}, + {file = "types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.dependencies] +pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "websocket-client" +version = "1.9.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"}, + {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["pytest", "websockets"] + +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + +[[package]] +name = "wheel" +version = "0.46.3" +description = "Command line tool for manipulating wheel files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d"}, + {file = "wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803"}, +] + +[package.dependencies] +packaging = ">=24.0" + +[package.extras] +test = ["pytest (>=6.0.0)", "setuptools (>=77)"] + +[[package]] +name = "wsproto" +version = "1.3.2" +description = "Pure-Python WebSocket protocol implementation" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"}, + {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"}, +] + +[package.dependencies] +h11 = ">=0.16.0,<1" + +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.14" +content-hash = "11e7820330aef0a91b8b1b35791aaa335cf1352f21316f2dedaf5439afe474ac" diff --git a/tests/poetry.toml b/tests/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/tests/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..ade5000 --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,8 @@ +[tool.poetry] +name = "nitter-tests" +version = "0.0.0" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.14" +seleniumbase = "4.46.5" diff --git a/tests/requirements.txt b/tests/requirements.txt index 56ea4c0..e47d1cc 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1 +1 @@ -seleniumbase +seleniumbase==4.46.5 From d187b1cc3f61f3046f4c6464a80ad705ea670c29 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 22 Feb 2026 07:02:45 +0100 Subject: [PATCH 198/309] Fix video thumbnails not loading Fixes #1371 --- src/routes/media.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/media.nim b/src/routes/media.nim index b3e5374..92eee5f 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -93,7 +93,7 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/orig\/(enc)?\/?(.+)": var url = decoded(request, 1) - cond "amplify_video" notin url + cond "/amplify_video/" notin url if "twimg.com" notin url: url.insert(twimg) @@ -109,7 +109,7 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/(enc)?\/?(.+)": var url = decoded(request, 1) - cond "amplify_video" notin url + cond "/amplify_video/" notin url if "twimg.com" notin url: url.insert(twimg) From b0773dd934afc4424a34074e8c8a7828d4dc3fea Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 4 Mar 2026 11:08:42 +0100 Subject: [PATCH 199/309] Fix incorrect multi-user search query Fixes #1373 --- src/query.nim | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/query.nim b/src/query.nim index c77bf5f..00bd08f 100644 --- a/src/query.nim +++ b/src/query.nim @@ -58,16 +58,18 @@ proc genQueryParam*(query: Query): string = if query.kind == users: return query.text + param = "(" for i, user in query.fromUser: - param &= &"from:{user} " + param &= &"from:{user}" if i < query.fromUser.high: - param &= "OR " + param &= " OR " + param &= ")" if query.fromUser.len > 0 and query.kind in {posts, media}: - param &= "filter:self_threads OR -filter:replies " + param &= " (filter:self_threads OR -filter:replies)" if "nativeretweets" notin query.excludes: - param &= "include:nativeretweets " + param &= " include:nativeretweets" for f in query.filters: filters.add "filter:" & f @@ -77,7 +79,11 @@ proc genQueryParam*(query: Query): string = for i in query.includes: filters.add "include:" & i - result = strip(param & filters.join(&" {query.sep} ")) + if filters.len > 0: + result = strip(param & " (" & filters.join(&" {query.sep} ") & ")") + else: + result = strip(param) + if query.since.len > 0: result &= " since:" & query.since if query.until.len > 0: From 2898efab6bf9cc667266cd622d8981dcbad46560 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 4 Mar 2026 11:53:57 +0100 Subject: [PATCH 200/309] Fix search repeating when the end has been reached --- public/js/infiniteScroll.js | 109 +++++++++++++++++++----------------- src/api.nim | 5 ++ src/routes/status.nim | 2 +- src/routes/timeline.nim | 3 +- 4 files changed, 65 insertions(+), 54 deletions(-) diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index be27e0c..a385edf 100644 --- a/public/js/infiniteScroll.js +++ b/public/js/infiniteScroll.js @@ -1,77 +1,82 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only function insertBeforeLast(node, elem) { - node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); + node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); } function getLoadMore(doc) { - return doc.querySelector(".show-more:not(.timeline-item)"); + return doc.querySelector(".show-more:not(.timeline-item)"); } function isDuplicate(item, itemClass) { - const tweet = item.querySelector(".tweet-link"); - if (tweet == null) return false; - const href = tweet.getAttribute("href"); - return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; + const tweet = item.querySelector(".tweet-link"); + if (tweet == null) return false; + const href = tweet.getAttribute("href"); + return ( + document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != + null + ); } window.onload = function () { - const url = window.location.pathname; - const isTweet = url.indexOf("/status/") !== -1; - const containerClass = isTweet ? ".replies" : ".timeline"; - const itemClass = containerClass + " > div:not(.top-ref)"; + const url = window.location.pathname; + const isTweet = url.indexOf("/status/") !== -1; + const containerClass = isTweet ? ".replies" : ".timeline"; + const itemClass = containerClass + " > div:not(.top-ref)"; - var html = document.querySelector("html"); - var container = document.querySelector(containerClass); - var loading = false; + var html = document.querySelector("html"); + var container = document.querySelector(containerClass); + var loading = false; - function handleScroll(failed) { - if (loading) return; + function handleScroll(failed) { + if (loading) return; - if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { - loading = true; - var loadMore = getLoadMore(document); - if (loadMore == null) return; + if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { + loading = true; + var loadMore = getLoadMore(document); + if (loadMore == null) return; - loadMore.children[0].text = "Loading..."; + loadMore.children[0].text = "Loading..."; - var url = new URL(loadMore.children[0].href); - url.searchParams.append("scroll", "true"); + var url = new URL(loadMore.children[0].href); + url.searchParams.append("scroll", "true"); - fetch(url.toString()).then(function (response) { - if (response.status === 404) throw "error"; + fetch(url.toString()) + .then(function (response) { + if (response.status > 299) throw "error"; + return response.text(); + }) + .then(function (html) { + var parser = new DOMParser(); + var doc = parser.parseFromString(html, "text/html"); + loadMore.remove(); - return response.text(); - }).then(function (html) { - var parser = new DOMParser(); - var doc = parser.parseFromString(html, "text/html"); - loadMore.remove(); + for (var item of doc.querySelectorAll(itemClass)) { + if (item.className == "timeline-item show-more") continue; + if (isDuplicate(item, itemClass)) continue; + if (isTweet) container.appendChild(item); + else insertBeforeLast(container, item); + } - for (var item of doc.querySelectorAll(itemClass)) { - if (item.className == "timeline-item show-more") continue; - if (isDuplicate(item, itemClass)) continue; - if (isTweet) container.appendChild(item); - else insertBeforeLast(container, item); - } + loading = false; + const newLoadMore = getLoadMore(doc); + if (newLoadMore == null) return; + if (isTweet) container.appendChild(newLoadMore); + else insertBeforeLast(container, newLoadMore); + }) + .catch(function (err) { + console.warn("Something went wrong.", err); + if (failed > 3) { + loadMore.children[0].text = "Error"; + return; + } - loading = false; - const newLoadMore = getLoadMore(doc); - if (newLoadMore == null) return; - if (isTweet) container.appendChild(newLoadMore); - else insertBeforeLast(container, newLoadMore); - }).catch(function (err) { - console.warn("Something went wrong.", err); - if (failed > 3) { - loadMore.children[0].text = "Error"; - return; - } - - loading = false; - handleScroll((failed || 0) + 1); - }); - } + loading = false; + handleScroll((failed || 0) + 1); + }); } + } - window.addEventListener("scroll", () => handleScroll()); + window.addEventListener("scroll", () => handleScroll()); }; // @license-end diff --git a/src/api.nim b/src/api.nim index ef44bd0..c9a6f59 100644 --- a/src/api.nim +++ b/src/api.nim @@ -168,6 +168,11 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = result = parseGraphSearch[Tweets](js, after) result.query = query + # when no more items are available the API just returns the last page in + # full. this detects that and clears the page instead. + if after.len > 0 and after[0..<64] == result.bottom[0..<64]: + result.content.setLen(0) + proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = if query.text.len == 0: return Result[User](query: query, beginning: true) diff --git a/src/routes/status.nim b/src/routes/status.nim index 7322837..ce093cb 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -27,7 +27,7 @@ proc createStatusRouter*(cfg: Config) = if @"scroll".len > 0: let replies = await getReplies(id, getCursor()) if replies.content.len == 0: - resp Http404, "" + resp Http204 resp $renderReplies(replies, prefs, getPath()) let conv = await getTweet(id, getCursor()) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 379281a..39552fd 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -129,7 +129,8 @@ proc createTimelineRouter*(cfg: Config) = if @"scroll".len > 0: if query.fromUser.len != 1: var timeline = await getGraphTweetSearch(query, after) - if timeline.content.len == 0: resp Http404 + if timeline.content.len == 0: + resp Http204 timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) else: From 4bf3df94f8b344ccc84bcaeb89541701de020905 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 4 Mar 2026 17:27:15 +0100 Subject: [PATCH 201/309] Fix segfault --- src/api.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api.nim b/src/api.nim index c9a6f59..ed19835 100644 --- a/src/api.nim +++ b/src/api.nim @@ -170,7 +170,8 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = # when no more items are available the API just returns the last page in # full. this detects that and clears the page instead. - if after.len > 0 and after[0..<64] == result.bottom[0..<64]: + if after.len > 0 and result.bottom.len > 0 and + after[0..<64] == result.bottom[0..<64]: result.content.setLen(0) proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = From 35a929c415be774cf0c96d65613217a8702be5ab Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 13 Mar 2026 05:47:37 +0100 Subject: [PATCH 202/309] Implement mixed-media tweet support Fixes #697 #1101 --- .gitignore | 2 + src/apiutils.nim | 1 + src/parser.nim | 48 +++++++---- src/parserutils.nim | 4 +- src/routes/embed.nim | 2 +- src/routes/status.nim | 16 ++-- src/sass/tweet/embed.scss | 2 +- src/sass/tweet/media.scss | 79 +++++++++++------- src/sass/tweet/quote.scss | 7 +- src/sass/tweet/video.scss | 28 +++---- src/types.nim | 42 +++++++++- src/views/embed.nim | 11 ++- src/views/general.nim | 2 +- src/views/rss.nimf | 69 ++++++++++------ src/views/tweet.nim | 165 ++++++++++++++++++++++---------------- 15 files changed, 304 insertions(+), 174 deletions(-) diff --git a/.gitignore b/.gitignore index dbd2f6b..09bdaa4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ nitter.conf guest_accounts.json* sessions.json* dump.rdb +*.bak +/tools/*.json* diff --git a/src/apiutils.nim b/src/apiutils.nim index ddb5027..42664cf 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -19,6 +19,7 @@ proc setDisableTid*(disable: bool) = disableTid = disable proc setApiProxy*(url: string) = + apiProxy = "" if url.len > 0: apiProxy = url.strip(chars={'/'}) & "/" if "http" notin apiProxy: diff --git a/src/parser.nim b/src/parser.nim index da0ffb4..a566fb1 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -140,27 +140,37 @@ proc parseVideo(js: JsonNode): Video = result.variants = parseVideoVariants(js{"video_info", "variants"}) +proc addMedia(media: var MediaEntities; photo: Photo) = + media.add Media(kind: photoMedia, photo: photo) + +proc addMedia(media: var MediaEntities; video: Video) = + media.add Media(kind: videoMedia, video: video) + +proc addMedia(media: var MediaEntities; gif: Gif) = + media.add Media(kind: gifMedia, gif: gif) + proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = with jsMedia, js{"extended_entities", "media"}: for m in jsMedia: case m.getTypeName: of "photo": - result.photos.add Photo( + result.media.addMedia(Photo( url: m{"media_url_https"}.getImageStr, altText: m{"ext_alt_text"}.getStr - ) + )) of "video": - result.video = some(parseVideo(m)) + result.media.addMedia(parseVideo(m)) with user, m{"additional_media_info", "source_user"}: if user{"id"}.getInt > 0: result.attribution = some(parseUser(user)) else: result.attribution = some(parseGraphUser(user)) of "animated_gif": - result.gif = some Gif( + result.media.addMedia(Gif( url: m{"video_info", "variants"}[0]{"url"}.getImageStr, - thumb: m{"media_url_https"}.getImageStr - ) + thumb: m{"media_url_https"}.getImageStr, + altText: m{"ext_alt_text"}.getStr + )) else: discard with url, m{"url"}: @@ -170,29 +180,35 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = proc parseMediaEntities(js: JsonNode; result: var Tweet) = with mediaEntities, js{"media_entities"}: + var parsedMedia: MediaEntities for mediaEntity in mediaEntities: with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: case mediaInfo.getTypeName of "ApiImage": - result.photos.add Photo( + parsedMedia.addMedia(Photo( url: mediaInfo{"original_img_url"}.getImageStr, altText: mediaInfo{"alt_text"}.getStr - ) + )) of "ApiVideo": let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} - result.video = some Video( + parsedMedia.addMedia(Video( available: status.getStr == "Available", thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, + title: mediaInfo{"alt_text"}.getStr, durationMs: mediaInfo{"duration_millis"}.getInt, variants: parseVideoVariants(mediaInfo{"variants"}) - ) + )) of "ApiGif": - result.gif = some Gif( + parsedMedia.addMedia(Gif( url: mediaInfo{"variants"}[0]{"url"}.getImageStr, - thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr - ) + thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, + altText: mediaInfo{"alt_text"}.getStr + )) else: discard + if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: + result.media = parsedMedia + # Remove media URLs from text with mediaList, js{"legacy", "entities", "media"}: for url in mediaList: @@ -348,13 +364,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); let name = jsCard{"name"}.getStr if "poll" in name: if "image" in name: - result.photos.add Photo( + result.media.addMedia(Photo( url: jsCard{"binding_values", "image_large"}.getImageVal - ) + )) result.poll = some parsePoll(jsCard) elif name == "amplify": - result.video = some parsePromoVideo(jsCard{"binding_values"}) + result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) else: result.card = some parseCard(jsCard, js{"entities", "urls"}) diff --git a/src/parserutils.nim b/src/parserutils.nim index 98dd2ee..ea37468 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -352,9 +352,7 @@ proc expandBirdwatchEntities*(text: string; entities: JsonNode): string = proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = let url = - if t.photos.len > 0: t.photos[0].url - elif t.video.isSome: get(t.video).thumb - elif t.gif.isSome: get(t.gif).thumb + if t.media.len > 0: t.media[0].getThumb elif t.card.isSome: get(t.card).image else: "" diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 0527d3d..bdca60f 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -11,7 +11,7 @@ proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": let tweet = await getGraphTweetResult(@"id") - if tweet == nil or tweet.video.isNone: + if tweet == nil or not tweet.hasVideos: resp Http404 resp renderVideoEmbed(tweet, cfg, request) diff --git a/src/routes/status.nim b/src/routes/status.nim index ce093cb..f7fb1bb 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -44,15 +44,19 @@ proc createStatusRouter*(cfg: Config) = desc = conv.tweet.text var - images = conv.tweet.photos.mapIt(it.url) + images = conv.tweet.getPhotos.mapIt(it.url) video = "" - if conv.tweet.video.isSome(): - images = @[get(conv.tweet.video).thumb] + let + firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind + else: photoMedia + + if firstMediaKind == videoMedia: + images = @[conv.tweet.media[0].getThumb] video = getVideoEmbed(cfg, conv.tweet.id) - elif conv.tweet.gif.isSome(): - images = @[get(conv.tweet.gif).thumb] - video = getPicUrl(get(conv.tweet.gif).url) + elif firstMediaKind == gifMedia: + images = @[conv.tweet.media[0].getThumb] + video = getPicUrl(conv.tweet.media[0].gif.url) elif conv.tweet.card.isSome(): let card = conv.tweet.card.get() if card.image.len > 0: diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index fbdbd41..bee23d3 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -11,7 +11,7 @@ left: 0%; } - .video-container { + .gallery-video > .attachment { max-height: unset; } } diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 040edda..d7b443e 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -10,9 +10,47 @@ max-width: 533px; pointer-events: all; - .still-image { - width: 100%; - align-self: center; + &.mixed-row { + .attachment { + min-width: 0; + min-height: 0; + flex: 1 1 0; + max-height: 379.5px; + display: flex; + align-items: center; + justify-content: center; + background-color: #101010; + } + + .still-image, + .still-image img, + .attachment > video, + .attachment > img { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + } + + .still-image { + display: flex; + align-self: stretch; + } + + .still-image img { + flex-basis: auto; + flex-grow: 0; + object-fit: cover; + } + + .attachment > video, + .attachment > img { + object-fit: cover; + } + + .attachment > video { + object-fit: contain; + } } } @@ -28,10 +66,6 @@ background-color: var(--bg_color); align-items: center; pointer-events: all; - - .image-attachment { - width: 100%; - } } .attachment { @@ -49,7 +83,14 @@ } } -.gallery-gif video { +.media-gif { + display: table; + background-color: unset; + width: unset; + max-height: unset; +} + +.media-gif video { max-height: 530px; background-color: #101010; } @@ -96,22 +137,6 @@ transition-property: max-height; } -.image { - display: flex; -} - -// .single-image { -// display: inline-block; -// width: 100%; -// max-height: 600px; - -// .attachments { -// width: unset; -// max-height: unset; -// display: inherit; -// } -// } - .overlay-circle { border-radius: 50%; background-color: var(--dark_grey); @@ -133,12 +158,6 @@ margin-left: 14px; } -.media-gif { - display: table; - background-color: unset; - width: unset; -} - .media-body { flex: 1; padding: 0; diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index 2f3122a..d6a75a9 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -95,7 +95,7 @@ justify-content: center; } - .gallery-gif .attachment { + .media-gif > .attachment { display: flex; justify-content: center; background-color: var(--bg_color); @@ -108,8 +108,9 @@ } } - .gallery-video, - .gallery-gif { + .gallery-row .attachment, + .gallery-row .attachment > video, + .gallery-row .attachment > img { max-height: 300px; } diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index ba77b14..c20d348 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -9,22 +9,22 @@ video { .gallery-video { display: flex; overflow: hidden; -} + + &.card-container { + flex-direction: column; + width: 100%; + } -.gallery-video.card-container { - flex-direction: column; - width: 100%; -} + > .attachment { + min-height: 80px; + min-width: 200px; + max-height: 530px; + margin: 0; -.video-container { - min-height: 80px; - min-width: 200px; - max-height: 530px; - margin: 0; - - img { - max-height: 100%; - max-width: 100%; + img { + max-height: 100%; + max-width: 100%; + } } } diff --git a/src/types.nim b/src/types.nim index 56bc2b2..3f655b6 100644 --- a/src/types.nim +++ b/src/types.nim @@ -136,11 +136,28 @@ type Gif* = object url*: string thumb*: string + altText*: string Photo* = object url*: string altText*: string + MediaKind* = enum + photoMedia + videoMedia + gifMedia + + Media* = object + case kind*: MediaKind + of photoMedia: + photo*: Photo + of videoMedia: + video*: Video + of gifMedia: + gif*: Gif + + MediaEntities* = seq[Media] + GalleryPhoto* = object url*: string tweetId*: string @@ -219,9 +236,7 @@ type quote*: Option[Tweet] card*: Option[Card] poll*: Option[Poll] - gif*: Option[Gif] - video*: Option[Video] - photos*: seq[Photo] + media*: MediaEntities history*: seq[int64] note*: string @@ -310,3 +325,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool = proc add*(timeline: var seq[Tweets]; tweet: Tweet) = timeline.add @[tweet] + +proc getPhotos*(tweet: Tweet): seq[Photo] = + tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo) + +proc getVideos*(tweet: Tweet): seq[Video] = + tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video) + +proc hasPhotos*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == photoMedia) + +proc hasVideos*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == videoMedia) + +proc hasGifs*(tweet: Tweet): bool = + tweet.media.anyIt(it.kind == gifMedia) + +proc getThumb*(media: Media): string = + case media.kind + of photoMedia: media.photo.url + of videoMedia: media.video.thumb + of gifMedia: media.gif.thumb diff --git a/src/views/embed.nim b/src/views/embed.nim index ba49f45..62cc76f 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -9,14 +9,17 @@ import general, tweet const doctype = "\n" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = - let thumb = get(tweet.video).thumb - let vidUrl = getVideoEmbed(cfg, tweet.id) - let prefs = Prefs(hlsPlayback: true, mp4Playback: true) + let + video = tweet.getVideos()[0] + thumb = video.thumb + vidUrl = getVideoEmbed(cfg, tweet.id) + prefs = Prefs(hlsPlayback: true, mp4Playback: true) + let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) body: tdiv(class="embed-video"): - renderVideo(get(tweet.video), prefs, "") + renderVideo(video, prefs, "") result = doctype & $node diff --git a/src/views/general.nim b/src/views/general.nim index ac087c1..9671c73 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=28") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=29") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 2c71591..4c1a86f 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -1,29 +1,38 @@ #? stdtmpl(subsChar = '$', metaChar = '#') ## SPDX-License-Identifier: AGPL-3.0-only -#import strutils, xmltree, strformat, options, unicode +#import strutils, sequtils, xmltree, strformat, options, unicode #import ../types, ../utils, ../formatters, ../prefs ## Snowflake ID cutoff for RSS GUID format transition ## Corresponds to approximately December 14, 2025 UTC #const guidCutoff = 2000000000000000000'i64 # #proc getTitle(tweet: Tweet; retweet: string): string = -#if tweet.pinned: result = "Pinned: " -#elif retweet.len > 0: result = &"RT by @{retweet}: " -#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: " +#var prefix = "" +#if tweet.pinned: prefix = "Pinned: " +#elif retweet.len > 0: prefix = &"RT by @{retweet}: " +#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: " #end if #var text = stripHtml(tweet.text) ##if unicode.runeLen(text) > 32: ## text = unicode.runeSubStr(text, 0, 32) & "..." ##end if -#result &= xmltree.escape(text) -#if result.len > 0: return +#text = xmltree.escape(text) +#if text.len > 0: +# result = prefix & text +# return #end if -#if tweet.photos.len > 0: -# result &= "Image" -#elif tweet.video.isSome: -# result &= "Video" -#elif tweet.gif.isSome: -# result &= "Gif" +#if tweet.media.len > 0: +# result = prefix +# let firstKind = tweet.media[0].kind +# if tweet.media.anyIt(it.kind != firstKind): +# result &= "Media" +# else: +# case firstKind +# of photoMedia: result &= "Image" +# of videoMedia: result &= "Video" +# of gifMedia: result &= "Gif" +# end case +# end if #end if #end proc # @@ -31,6 +40,26 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} #end proc # +#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string = +#case media.kind +#of photoMedia: +# let photo = media.photo + +#of videoMedia: +# let video = media.video + +
Video
+ +
+#of gifMedia: +# let gif = media.gif +# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}" +# let url = &"{urlPrefix}{getPicUrl(gif.url)}" + +#end case +#end proc +# #proc getTweetsWithPinned(profile: Profile): seq[Tweets] = #result = profile.tweets.content #if profile.pinned.isSome and result.len > 0: @@ -54,20 +83,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} #let urlPrefix = getUrlPrefix(cfg) #let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)

${text.replace("\n", "
\n")}

-#if tweet.photos.len > 0: -# for photo in tweet.photos: - +#if tweet.media.len > 0: +# for media in tweet.media: +${renderRssMedia(media, tweet, urlPrefix)} # end for -#elif tweet.video.isSome: - -
Video
- -
-#elif tweet.gif.isSome: -# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" -# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" - #elif tweet.card.isSome: # let card = tweet.card.get() # if card.image.len > 0: diff --git a/src/views/tweet.nim b/src/views/tweet.nim index b0578c1..b1f805c 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -38,24 +38,19 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN a(href=getLink(tweet), title=tweet.getTime): text tweet.getShortTime -proc renderAlbum(tweet: Tweet): VNode = - let - groups = if tweet.photos.len < 3: @[tweet.photos] - else: tweet.photos.distribute(2) +proc renderAltText(altText: string): VNode = + buildHtml(p(class="alt-text")): + text "ALT " & altText - buildHtml(tdiv(class="attachments")): - for i, photos in groups: - let margin = if i > 0: ".25em" else: "" - tdiv(class="gallery-row", style={marginTop: margin}): - for photo in photos: - tdiv(class="attachment image"): - let - named = "name=" in photo.url - small = if named: photo.url else: photo.url & smallWebp - a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): - genImg(small, alt=photo.altText) - if photo.altText.len > 0: - p(class="alt-text"): text "ALT " & photo.altText +proc renderPhotoAttachment(photo: Photo): VNode = + buildHtml(tdiv(class="attachment")): + let + named = "name=" in photo.url + small = if named: photo.url else: photo.url & smallWebp + a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): + genImg(small, alt=photo.altText) + if photo.altText.len > 0: + renderAltText(photo.altText) proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = case playbackType @@ -65,7 +60,7 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = proc hasMp4Url(video: Video): bool = video.variants.anyIt(it.contentType == mp4) -proc renderVideoDisabled(playbackType: VideoType; path: string): VNode = +proc renderVideoDisabled(playbackType: VideoType; path=""): VNode = buildHtml(tdiv(class="video-overlay")): case playbackType of mp4: @@ -81,52 +76,97 @@ proc renderVideoUnavailable(video: Video): VNode = else: p: text "This media is unavailable" -proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = +proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""): 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 - else: video.playbackType + playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4 + else: videoData.playbackType + thumb = getSmallPic(videoData.thumb) + + buildHtml(tdiv(class="attachment")): + if not videoData.available: + img(src=thumb, loading="lazy") + renderVideoUnavailable(videoData) + elif not prefs.isPlaybackEnabled(playbackType): + img(src=thumb, loading="lazy") + renderVideoDisabled(playbackType, path) + else: + let + vars = videoData.variants.filterIt(it.contentType == playbackType) + vidUrl = vars.sortedByIt(it.resolution)[^1].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") + of m3u8, vmap: + video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) + verbatim "
" + tdiv(class="overlay-circle"): span(class="overlay-triangle") + tdiv(class="overlay-duration"): text getDuration(videoData) + verbatim "
" + +proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = + let hasCardContent = video.description.len > 0 or video.title.len > 0 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, loading="lazy") - renderVideoUnavailable(video) - elif not prefs.isPlaybackEnabled(playbackType): - img(src=thumb, loading="lazy") - renderVideoDisabled(playbackType, path) - else: - let - vars = video.variants.filterIt(it.contentType == playbackType) - vidUrl = vars.sortedByIt(it.resolution)[^1].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") - of m3u8, vmap: - video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) - verbatim "
" - tdiv(class="overlay-circle"): span(class="overlay-triangle") - tdiv(class="overlay-duration"): text getDuration(video) - verbatim "
" - if container.len > 0: + tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))): + renderVideoAttachment(video, prefs, path) + if hasCardContent: tdiv(class="card-content"): h2(class="card-title"): text video.title if video.description.len > 0: p(class="card-description"): text video.description +proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode = + let thumb = getSmallPic(gif.thumb) + + buildHtml(tdiv(class="attachment")): + if not prefs.mp4Playback: + img(src=thumb, loading="lazy") + renderVideoDisabled(mp4) + elif prefs.autoplayGifs: + video(class="gif", poster=thumb, autoplay="", muted="", loop=""): + source(src=getPicUrl(gif.url), `type`="video/mp4") + else: + video(class="gif", poster=thumb, controls="", muted="", loop=""): + source(src=getPicUrl(gif.url), `type`="video/mp4") + if gif.altText.len > 0: + renderAltText(gif.altText) + proc renderGif(gif: Gif; prefs: Prefs): VNode = buildHtml(tdiv(class="attachments media-gif")): - tdiv(class="gallery-gif", style={maxHeight: "unset"}): - tdiv(class="attachment"): - video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs, - controls="", muted="", loop=""): - source(src=getPicUrl(gif.url), `type`="video/mp4") + renderGifAttachment(gif, prefs) + +proc renderMedia(media: seq[Media]; prefs: Prefs; path: string): VNode = + if media.len == 0: + return nil + + if media.len == 1: + let item = media[0] + if item.kind == videoMedia: + return renderVideo(item.video, prefs, path) + if item.kind == gifMedia: + return renderGif(item.gif, prefs) + + let + groups = if media.len < 3: @[media] + else: media.distribute(2) + + buildHtml(tdiv(class="attachments")): + for i, mediaGroup in groups: + let margin = if i > 0: ".25em" else: "" + let rowClass = "gallery-row" & + (if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row") + tdiv(class=rowClass, style={marginTop: margin}): + for mediaItem in mediaGroup: + case mediaItem.kind + of photoMedia: + renderPhotoAttachment(mediaItem.photo) + of videoMedia: + renderVideoAttachment(mediaItem.video, prefs, path) + of gifMedia: + renderGifAttachment(mediaItem.gif, prefs) proc renderPoll(poll: Poll): VNode = buildHtml(tdiv(class="poll")): @@ -219,12 +259,7 @@ proc renderLatestPost(username: string; id: int64): VNode = proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="quote-media-container")): - if quote.photos.len > 0: - renderAlbum(quote) - elif quote.video.isSome: - renderVideo(quote.video.get(), prefs, path) - elif quote.gif.isSome: - renderGif(quote.gif.get(), prefs) + renderMedia(quote.media, prefs, path) proc renderCommunityNote(note: string; prefs: Prefs): VNode = buildHtml(tdiv(class="community-note")): @@ -266,7 +301,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="quote-text", dir="auto"): verbatim replaceUrls(quote.text, prefs) - if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: + if quote.media.len > 0: renderQuoteMedia(quote, prefs, path) if quote.note.len > 0 and not prefs.hideCommunityNotes: @@ -344,12 +379,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.card.isSome and tweet.card.get().kind != hidden: renderCard(tweet.card.get(), prefs, path) - if tweet.photos.len > 0: - renderAlbum(tweet) - elif tweet.video.isSome: - renderVideo(tweet.video.get(), prefs, path) - elif tweet.gif.isSome: - renderGif(tweet.gif.get(), prefs) + if tweet.media.len > 0: + renderMedia(tweet.media, prefs, path) if tweet.poll.isSome: renderPoll(tweet.poll.get()) From 0fefcf9917c1a95e73ca10bd72a9cd487ad5a0dc Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 13 Mar 2026 06:19:09 +0100 Subject: [PATCH 203/309] Update gif class in tests --- tests/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/base.py b/tests/base.py index 010dfbb..81371d3 100644 --- a/tests/base.py +++ b/tests/base.py @@ -79,7 +79,7 @@ class Media(object): row = '.gallery-row' image = '.still-image' video = '.gallery-video' - gif = '.gallery-gif' + gif = '.media-gif' class BaseTestCase(BaseCase): From 91ff936cb39170450b9d769cf433ba758ac6e362 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 14 Mar 2026 04:04:18 +0100 Subject: [PATCH 204/309] Add workaround for broken "until" search filter Fixes #1372 --- src/api.nim | 15 ++++++++++----- src/query.nim | 21 +++++++++++---------- src/utils.nim | 7 ++++++- src/views/timeline.nim | 22 +++++++++++++++++++++- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/api.nim b/src/api.nim index ed19835..68e8eec 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import asyncdispatch, httpclient, strutils, sequtils, sugar import packedjson -import types, query, formatters, consts, apiutils, parser +import types, query, formatters, consts, apiutils, parser, utils import experimental/parser as newParser # Helper to generate params object for GraphQL requests @@ -146,7 +146,12 @@ proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} = result = parseGraphEditHistory(js, id) proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = - let q = genQueryParam(query) + # workaround for #1372 + let maxId = + if not after.startsWith("maxid:"): "" + else: validateNumber(after[6..^1]) + + let q = genQueryParam(query, maxId) if q.len == 0 or q == emptyQuery: return Timeline(query: query, beginning: true) @@ -160,9 +165,9 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = "withReactionsMetadata": false, "withReactionsPerspective": false } - if after.len > 0: + if after.len > 0 and maxId.len == 0: variables["cursor"] = % after - let + let url = apiReq(graphSearchTimeline, $variables) js = await fetch(url) result = parseGraphSearch[Tweets](js, after) @@ -170,7 +175,7 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = # when no more items are available the API just returns the last page in # full. this detects that and clears the page instead. - if after.len > 0 and result.bottom.len > 0 and + if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and after[0..<64] == result.bottom[0..<64]: result.content.setLen(0) diff --git a/src/query.nim b/src/query.nim index 00bd08f..4d2ef78 100644 --- a/src/query.nim +++ b/src/query.nim @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import strutils, strformat, sequtils, tables, uri -import types +import types, utils const validFilters* = @[ @@ -17,11 +17,6 @@ template `@`(param: string): untyped = if param in pms: pms[param] else: "" -proc validateNumber(value: string): string = - if value.anyIt(not it.isDigit): - return "" - return value - proc initQuery*(pms: Table[string, string]; name=""): Query = result = Query( kind: parseEnum[QueryKind](@"f", tweets), @@ -50,7 +45,7 @@ proc getReplyQuery*(name: string): Query = fromUser: @[name] ) -proc genQueryParam*(query: Query): string = +proc genQueryParam*(query: Query; maxId=""): string = var filters: seq[string] param: string @@ -58,12 +53,15 @@ proc genQueryParam*(query: Query): string = if query.kind == users: return query.text - param = "(" for i, user in query.fromUser: + if i == 0: + param = "(" + param &= &"from:{user}" if i < query.fromUser.high: param &= " OR " - param &= ")" + else: + param &= ")" if query.fromUser.len > 0 and query.kind in {posts, media}: param &= " (filter:self_threads OR -filter:replies)" @@ -86,7 +84,7 @@ proc genQueryParam*(query: Query): string = if query.since.len > 0: result &= " since:" & query.since - if query.until.len > 0: + if query.until.len > 0 and maxId.len == 0: result &= " until:" & query.until if query.minLikes.len > 0: result &= " min_faves:" & query.minLikes @@ -96,6 +94,9 @@ proc genQueryParam*(query: Query): string = else: result = query.text + if result.len > 0 and maxId.len > 0: + result &= " max_id:" & maxId + proc genQueryUrl*(query: Query): string = if query.kind notin {tweets, users}: return diff --git a/src/utils.nim b/src/utils.nim index 667299c..6bd977e 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, uri, tables, base64 +import sequtils, strutils, strformat, uri, tables, base64 import nimcrypto var @@ -59,3 +59,8 @@ proc isTwitterUrl*(uri: Uri): bool = proc isTwitterUrl*(url: string): bool = isTwitterUrl(parseUri(url)) + +proc validateNumber*(value: string): string = + if value.anyIt(not it.isDigit): + return "" + return value diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 317b7e3..2d7537f 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -11,6 +11,23 @@ proc getQuery(query: Query): string = if result.len > 0: result &= "&" +proc getSearchMaxId(results: Timeline; path: string): string = + if results.query.kind != tweets or results.content.len == 0 or + results.query.until.len == 0: + return + + let lastThread = results.content[^1] + if lastThread.len == 0 or lastThread[^1].id == 0: + return + + # 2000000 is the minimum decrement to guarantee no result overlap + var maxId = lastThread[^1].id - 2_000_000'i64 + if maxId <= 0: + maxId = lastThread[^1].id - 1 + + if maxId > 0: + return "maxid:" & $maxId + proc renderToTop*(focus="#"): VNode = buildHtml(tdiv(class="top-ref")): icon "down", href=focus @@ -122,6 +139,9 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; else: renderThread(thread, prefs, path) - if results.bottom.len > 0: + var cursor = getSearchMaxId(results, path) + if cursor.len > 0: + renderMore(results.query, cursor) + elif results.bottom.len > 0: renderMore(results.query, results.bottom) renderToTop() From 7ce29bd8f1725a95b091abb2ede174edd167f1ad Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 15 Mar 2026 09:29:00 +0100 Subject: [PATCH 205/309] Add new media grid and gallery views Fixes #199 Fixes #1342 --- public/js/infiniteScroll.js | 248 ++++++++++++++++++++------ src/api.nim | 16 +- src/consts.nim | 4 +- src/prefs_impl.nim | 7 + src/query.nim | 37 ++-- src/routes/timeline.nim | 20 ++- src/sass/inputs.scss | 1 + src/sass/profile/_base.scss | 145 +++++++++------ src/sass/timeline.scss | 341 +++++++++++++++++++++++++++++++++++- src/sass/tweet/_base.scss | 3 + src/types.nim | 1 + src/views/general.nim | 2 +- src/views/profile.nim | 19 +- src/views/search.nim | 18 +- src/views/timeline.nim | 50 ++++-- tests/base.py | 7 + tests/test_timeline.py | 22 +++ 17 files changed, 767 insertions(+), 174 deletions(-) diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index a385edf..1ea35d1 100644 --- a/public/js/infiniteScroll.js +++ b/public/js/infiniteScroll.js @@ -1,5 +1,6 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only + function insertBeforeLast(node, elem) { node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); } @@ -8,75 +9,210 @@ function getLoadMore(doc) { return doc.querySelector(".show-more:not(.timeline-item)"); } -function isDuplicate(item, itemClass) { - const tweet = item.querySelector(".tweet-link"); - if (tweet == null) return false; - const href = tweet.getAttribute("href"); - return ( - document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != - null - ); +function getHrefs(selector) { + return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href"))); } -window.onload = function () { - const url = window.location.pathname; - const isTweet = url.indexOf("/status/") !== -1; - const containerClass = isTweet ? ".replies" : ".timeline"; - const itemClass = containerClass + " > div:not(.top-ref)"; +function getTweetId(item) { + const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/); + return m ? m[1] : ""; +} - var html = document.querySelector("html"); - var container = document.querySelector(containerClass); - var loading = false; +function isDuplicate(item, hrefs) { + return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href")); +} - function handleScroll(failed) { - if (loading) return; +const GAP = 10; - if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { - loading = true; - var loadMore = getLoadMore(document); - if (loadMore == null) return; +class Masonry { + constructor(container) { + this.container = container; + this.colHeights = []; + this.colCounts = []; + this.colCount = 0; + this._lastWidth = 0; + this._colWidthCache = 0; + this._items = []; + this._revealTimer = null; + this.container.classList.add("masonry-active"); - loadMore.children[0].text = "Loading..."; + let resizeTimer; + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => this._rebuild(), 50); + }); - var url = new URL(loadMore.children[0].href); - url.searchParams.append("scroll", "true"); + // Re-sync positions whenever images finish loading and items grow taller. + // Must be set up before _rebuild() so initial items get observed on first pass. + let syncTimer; + this._observer = window.ResizeObserver ? new ResizeObserver(() => { + clearTimeout(syncTimer); + syncTimer = setTimeout(() => this.syncHeights(), 100); + }) : null; - fetch(url.toString()) - .then(function (response) { - if (response.status > 299) throw "error"; - return response.text(); - }) - .then(function (html) { - var parser = new DOMParser(); - var doc = parser.parseFromString(html, "text/html"); - loadMore.remove(); + this._rebuild(); + } - for (var item of doc.querySelectorAll(itemClass)) { - if (item.className == "timeline-item show-more") continue; - if (isDuplicate(item, itemClass)) continue; - if (isTweet) container.appendChild(item); - else insertBeforeLast(container, item); - } + // Reveal all items and gallery siblings (show-more, top-ref). Idempotent. + _revealAll() { + clearTimeout(this._revealTimer); + for (const item of this._items) item.classList.add("masonry-visible"); + for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer")) + el.classList.add("masonry-visible"); + } - loading = false; - const newLoadMore = getLoadMore(doc); - if (newLoadMore == null) return; - if (isTweet) container.appendChild(newLoadMore); - else insertBeforeLast(container, newLoadMore); - }) - .catch(function (err) { - console.warn("Something went wrong.", err); - if (failed > 3) { - loadMore.children[0].text = "Error"; - return; - } + // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images. + _pickCol() { + return this.colHeights.reduce((min, h, i) => { + const m = this.colHeights[min]; + return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min; + }, 0); + } - loading = false; - handleScroll((failed || 0) + 1); - }); + // Position items using current column state. Updates colHeights, colCounts, container height. + _position(items, heights, colWidth) { + for (let i = 0; i < items.length; i++) { + const col = this._pickCol(); + items[i].style.left = `${col * (colWidth + GAP)}px`; + items[i].style.top = `${this.colHeights[col]}px`; + this.colHeights[col] += heights[i] + GAP; + this.colCounts[col]++; + } + this.container.style.height = `${Math.max(0, ...this.colHeights)}px`; + } + + // Full reset and re-place all items. + _place(items, heights, n, colWidth) { + this.colHeights = new Array(n).fill(0); + this.colCounts = new Array(n).fill(0); + this.colCount = n; + this._position(items, heights, colWidth); + } + + _rebuild() { + const n = Math.max(1, Math.floor(this.container.clientWidth / 350)); + const w = this.container.clientWidth; + if (n === this.colCount && w === this._lastWidth) return; + + const isFirst = this.colCount === 0; + + if (isFirst) { + this._items = [...this.container.querySelectorAll(".timeline-item")]; + } + + // Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings). + this._items.sort((a, b) => { + const idA = getTweetId(a), idB = getTweetId(b); + if (idA.length !== idB.length) return idB.length - idA.length; + return idB < idA ? -1 : idB > idA ? 1 : 0; + }); + + // Pre-set widths BEFORE reading heights so measurements reflect the new column width. + const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n); + for (const item of this._items) item.style.width = `${colWidth}px`; + + this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth); + this._lastWidth = w; + + if (isFirst) { + if (this._observer) this._items.forEach(item => this._observer.observe(item)); + // Reveal immediately if all images are cached, else wait for syncHeights. + const hasUnloaded = this._items.some(item => + [...item.querySelectorAll("img")].some(img => !img.complete)); + if (hasUnloaded) { + this._revealTimer = setTimeout(() => this._revealAll(), 1000); + } else { + this._revealAll(); + } } } + // Re-read actual heights and re-place all items. Fixes drift after images load. + syncHeights() { + this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache); + this._revealAll(); + } + + // Batch-add items in three phases to avoid O(N) reflows: + // 1. writes: set widths, append all — no reads, no reflows + // 2. one read: batch offsetHeight + // 3. writes: assign columns, set left/top + addAll(newItems) { + if (!newItems.length) return; + const colWidth = this._colWidthCache; + + for (const item of newItems) { + item.style.width = `${colWidth}px`; + this.container.appendChild(item); + } + + this._position(newItems, newItems.map(item => item.offsetHeight), colWidth); + this._items.push(...newItems); + + if (this._observer) newItems.forEach(item => this._observer.observe(item)); + } +} + +document.addEventListener("DOMContentLoaded", function () { + const isTweet = location.pathname.includes("/status/"); + const containerClass = isTweet ? ".replies" : ".timeline"; + const itemClass = containerClass + " > div:not(.top-ref)"; + const html = document.documentElement; + const container = document.querySelector(containerClass); + const masonryEl = container?.querySelector(".gallery-masonry"); + const masonry = masonryEl ? new Masonry(masonryEl) : null; + let loading = false; + + function handleScroll(failed) { + if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return; + + const loadMore = getLoadMore(document); + if (!loadMore) return; + loading = true; + loadMore.children[0].text = "Loading..."; + + const url = new URL(loadMore.children[0].href); + url.searchParams.append("scroll", "true"); + + fetch(url) + .then(r => { + if (r.status > 299) throw new Error("error"); + return r.text(); + }) + .then(responseText => { + const doc = new DOMParser().parseFromString(responseText, "text/html"); + loadMore.remove(); + + if (masonry) { + masonry.syncHeights(); + const newMasonry = doc.querySelector(".gallery-masonry"); + if (newMasonry) { + const knownHrefs = getHrefs(".gallery-masonry .tweet-link"); + masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs))); + } + } else { + const knownHrefs = getHrefs(`${itemClass} .tweet-link`); + for (const item of doc.querySelectorAll(itemClass)) { + if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue; + isTweet ? container.appendChild(item) : insertBeforeLast(container, item); + } + } + + loading = false; + const newLoadMore = getLoadMore(doc); + if (newLoadMore) { + isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore); + if (masonry) newLoadMore.classList.add("masonry-visible"); + } + }) + .catch(err => { + console.warn("Something went wrong.", err); + if (failed > 3) { loadMore.children[0].text = "Error"; return; } + loading = false; + handleScroll((failed || 0) + 1); + }); + } + window.addEventListener("scroll", () => handleScroll()); -}; +}); // @license-end diff --git a/src/api.nim b/src/api.nim index 68e8eec..9e496a4 100644 --- a/src/api.nim +++ b/src/api.nim @@ -18,16 +18,16 @@ proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq = let url = apiUrl(endpoint, variables, fieldToggles) return ApiReq(cookie: url, oauth: url) -proc mediaUrl(id: string; cursor: string): ApiReq = +proc mediaUrl(id, cursor: string; count=20): ApiReq = result = ApiReq( - cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]), - oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor]) + cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]), + oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count]) ) proc userTweetsUrl(id: string; cursor: string): ApiReq = result = ApiReq( # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), - oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor]) + oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"]) ) # might change this in the future pending testing result.cookie = result.oauth @@ -36,7 +36,7 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = let cookieVars = userTweetsAndRepliesVars % [id, cursor] result = ApiReq( cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles), - oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor]) + oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"]) ) proc tweetDetailUrl(id: string; cursor: string): ApiReq = @@ -73,7 +73,7 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi url = case kind of TimelineKind.tweets: userTweetsUrl(id, cursor) of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) - of TimelineKind.media: mediaUrl(id, cursor) + of TimelineKind.media: mediaUrl(id, cursor, 100) js = await fetch(url) result = parseGraphTimeline(js, after) @@ -81,7 +81,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - url = apiReq(graphListTweets, restIdVars % [id, cursor]) + url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"]) js = await fetch(url) result = parseGraphTimeline(js, after).tweets @@ -205,7 +205,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return - let js = await fetch(mediaUrl(id, "")) + let js = await fetch(mediaUrl(id, "", 30)) result = parseGraphPhotoRail(js) proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = diff --git a/src/consts.nim b/src/consts.nim index 17827f2..cea9cc8 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -138,12 +138,12 @@ const restIdVars* = """{ "rest_id": "$1", $2 - "count": 20 + "count": $3 }""" userMediaVars* = """{ "userId": "$1", $2 - "count": 20, + "count": $3, "includePromotedContent": false, "withClientEventToken": false, "withBirdwatchNotes": false, diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 047caef..8ad2413 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -100,6 +100,13 @@ genPrefs: autoplayGifs(checkbox, true): "Autoplay gifs" + compactGallery(checkbox, false): + "Compact media gallery (no profile info or text)" + + mediaView(select, "Timeline"): + "Default media view" + options: @["Timeline", "Grid", "Gallery"] + "Link replacements (blank to disable)": replaceTwitter(input, ""): "Twitter -> Nitter" diff --git a/src/query.nim b/src/query.nim index 4d2ef78..38fe6f4 100644 --- a/src/query.nim +++ b/src/query.nim @@ -20,6 +20,7 @@ template `@`(param: string): untyped = proc initQuery*(pms: Table[string, string]; name=""): Query = result = Query( kind: parseEnum[QueryKind](@"f", tweets), + view: @"view", text: @"q", filters: validFilters.filterIt("f-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms), @@ -98,24 +99,28 @@ proc genQueryParam*(query: Query; maxId=""): string = result &= " max_id:" & maxId proc genQueryUrl*(query: Query): string = - if query.kind notin {tweets, users}: return + var params: seq[string] - var params = @[&"f={query.kind}"] - if query.text.len > 0: - params.add "q=" & encodeUrl(query.text) - for f in query.filters: - params.add &"f-{f}=on" - for e in query.excludes: - params.add &"e-{e}=on" - for i in query.includes.filterIt(it != "nativeretweets"): - params.add &"i-{i}=on" + if query.view.len > 0: + params.add "view=" & encodeUrl(query.view) - if query.since.len > 0: - params.add "since=" & query.since - if query.until.len > 0: - params.add "until=" & query.until - if query.minLikes.len > 0: - params.add "min_faves=" & query.minLikes + if query.kind in {tweets, users}: + params.add &"f={query.kind}" + if query.text.len > 0: + params.add "q=" & encodeUrl(query.text) + for f in query.filters: + params.add &"f-{f}=on" + for e in query.excludes: + params.add &"e-{e}=on" + for i in query.includes.filterIt(it != "nativeretweets"): + params.add &"i-{i}=on" + + if query.since.len > 0: + params.add "since=" & query.since + if query.until.len > 0: + params.add "until=" & query.until + if query.minLikes.len > 0: + params.add "min_faves=" & query.minLikes if params.len > 0: result &= params.join("&") diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 39552fd..8a11e0e 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -12,12 +12,20 @@ export router_utils export redis_cache, formatters, query, api export profile, timeline, status -proc getQuery*(request: Request; tab, name: string): Query = +proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query = + let view = request.params.getOrDefault("view") case tab - of "with_replies": getReplyQuery(name) - of "media": getMediaQuery(name) - of "search": initQuery(params(request), name=name) - else: Query(fromUser: @[name]) + of "with_replies": + result = getReplyQuery(name) + of "media": + result = getMediaQuery(name) + result.view = + if view in ["timeline", "grid", "gallery"]: view + else: prefs.mediaView.toLowerAscii + of "search": + result = initQuery(params(request), name=name) + else: + result = Query(fromUser: @[name]) template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = if cond: @@ -121,7 +129,7 @@ proc createTimelineRouter*(cfg: Config) = after = getCursor() names = getNames(@"name") - var query = request.getQuery(@"tab", @"name") + var query = request.getQuery(@"tab", @"name", prefs) if names.len != 1: query.fromUser = names diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index c69711a..2b6016f 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -179,6 +179,7 @@ input::-webkit-datetime-edit-year-field:focus { -moz-appearance: none; -webkit-appearance: none; appearance: none; + min-width: 100px; } input[type="text"], diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index 3abc736..4daddf6 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -1,87 +1,116 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; -@import 'card'; -@import 'photo-rail'; +@import "card"; +@import "photo-rail"; .profile-tabs { - @include panel(auto, 900px); + @include panel(auto, 900px); - .timeline-container { - float: right; - width: 68% !important; - max-width: unset; - } + .timeline-container { + float: right; + width: 68% !important; + max-width: unset; + } } .profile-banner { - margin-bottom: 4px; - background-color: var(--bg_panel); + margin-bottom: 4px; + background-color: var(--bg_panel); - a { - display: block; - position: relative; - padding: 33.34% 0 0 0; - } + a { + display: block; + position: relative; + padding: 33.34% 0 0 0; + } - img { - max-width: 100%; - position: absolute; - top: 0; - } + img { + max-width: 100%; + position: absolute; + top: 0; + } } .profile-tab { - padding: 0 4px 0 0; - box-sizing: border-box; - display: inline-block; - font-size: 14px; - text-align: left; - vertical-align: top; - max-width: 32%; - top: 0; + padding: 0 4px 0 0; + box-sizing: border-box; + display: inline-block; + font-size: 14px; + text-align: left; + vertical-align: top; + max-width: 32%; + top: 0; - body.fixed-nav & { - top: 50px; - } + body.fixed-nav & { + top: 50px; + } } .profile-result { - min-height: 54px; + min-height: 54px; - .username { - margin: 0 !important; - } + .username { + margin: 0 !important; + } - .tweet-header { - margin-bottom: unset; - } + .tweet-header { + margin-bottom: unset; + } } -@media(max-width: 700px) { - .profile-tabs { - width: 100vw; - max-width: 600px; +.profile-tabs.media-only { + max-width: none; + width: 100%; - .timeline-container { - width: 100% !important; + .timeline-container { + float: none; + width: 100% !important; + max-width: none; + padding: 0 10px; + box-sizing: border-box; + } - .tab-item wide { - flex-grow: 1.4; - } - } + .timeline-container > .tab { + max-width: 900px; + margin-left: auto; + margin-right: auto; + } +} + +@media (max-width: 700px) { + .profile-tabs { + width: 100vw; + max-width: 600px; + + .timeline-container { + width: 100% !important; + + .tab-item wide { + flex-grow: 1.4; + } } + } - .profile-tab { - width: 100%; - max-width: unset; - position: initial !important; - padding: 0; + .profile-tabs.media-only { + width: 100%; + max-width: none; + + .timeline-container { + width: 100vw !important; + padding: 0; } + } + + .profile-tab { + width: 100%; + max-width: unset; + position: initial !important; + padding: 0; + } } @media (min-height: 900px) { - .profile-tab.sticky { - position: sticky; - } + .profile-tab.sticky { + position: sticky; + } } diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index eeb794a..192073f 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -15,7 +15,7 @@ padding: 8px; display: block; font-weight: bold; - margin-bottom: 5px; + margin-bottom: 4px; box-sizing: border-box; button { @@ -36,7 +36,7 @@ display: flex; flex-wrap: wrap; list-style: none; - margin: 0 0 5px 0; + margin: 0 0 4px 0; background-color: var(--bg_panel); padding: 0; } @@ -157,3 +157,340 @@ position: relative; background-color: var(--bg_panel); } + +.timeline.media-grid-view, +.timeline.media-gallery-view { + > div:not(:first-child) { + border-top: none; + } + + .timeline-item::before { + display: none; + } +} + +.timeline.media-grid-view, +.timeline.media-gallery-view .gallery-masonry.compact { + .tweet-header, + .replying-to, + .retweet-header, + .pinned, + .tweet-stats, + .attribution, + .poll, + .quote, + .community-note, + .media-tag-block, + .tweet-content, + .card-content { + display: none; + } + + .card { + margin: unset; + + .card-container { + border: unset; + border-radius: unset; + + .card-image-container { + width: 100%; + min-height: 100%; + } + + .card-content-container { + display: none; + } + } + } +} + +.timeline.media-grid-view { + display: grid; + gap: 4px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + + > div:not(:first-child) { + margin-top: 0; + } + + .timeline-item { + padding: 0; + } + + .tweet-link { + z-index: 1000; + + &:hover { + background-color: unset; + } + } + + > .show-more, + > .top-ref, + > .timeline-footer, + > .timeline-header { + grid-column: 1 / -1; + } + + .tweet-body { + height: 100%; + margin-left: 0; + padding: 0; + position: relative; + aspect-ratio: 1/1; + } + + .gallery-row + .gallery-row { + margin-top: 0.25em !important; + } + + .attachments { + background-color: var(--darkest_grey); + border-radius: 0; + margin: 0; + max-height: none; + } + + .attachments, + .gallery-row, + .still-image { + height: 100%; + width: 100%; + } + + .still-image img, + .attachment > video, + .attachment > img { + object-fit: cover; + height: 100%; + width: 100%; + } + + .attachment { + display: flex; + align-items: center; + } + + .gallery-video { + height: 100%; + } + + .media-gif { + display: flex; + } + + .timeline-item:hover { + opacity: 0.85; + } + + .alt-text { + display: none; + } +} + +.timeline.media-gallery-view { + .gallery-masonry { + margin: 10px 0; + column-gap: 10px; + column-width: 350px; + + &.masonry-active { + column-width: unset; + column-gap: unset; + position: relative; + + .timeline-item { + animation: none; + position: absolute; + box-sizing: border-box; + margin-bottom: 0; + } + } + + &.compact { + .tweet-body { + padding: 0; + + > .attachments { + margin: 0; + } + } + + .card-image-container img { + max-height: unset; + } + } + } + + @keyframes masonry-init { + to { + opacity: 1; + pointer-events: auto; + } + } + + // Start hidden. CSS animation reveals after a delay as a no-JS fallback. + // With JS, masonry-active cancels the animation and masonry-visible reveals. + .gallery-masonry .timeline-item, + > .show-more, + > .top-ref, + > .timeline-footer { + opacity: 0; + pointer-events: none; + animation: masonry-init 0.2s 0.3s forwards; + } + + .gallery-masonry.masonry-active .timeline-item.masonry-visible, + > .show-more.masonry-visible, + > .top-ref.masonry-visible, + > .timeline-footer.masonry-visible { + opacity: 1; + pointer-events: auto; + transition: opacity 0.15s ease; + animation: none; + } + + .timeline-item { + margin-bottom: 10px; + break-inside: avoid; + flex-direction: column; + padding: 0; + } + + > .show-more, + > .top-ref, + > .timeline-footer, + > .timeline-header { + margin-left: auto; + margin-right: auto; + max-width: 900px; + } + + > .show-more { + padding: 0; + margin-top: 8px; + background-color: unset; + } + + .tweet-content { + margin: 3px 0; + } + + .tweet-body { + display: flex; + flex-direction: column; + height: 100%; + margin-left: 0; + padding: 10px; + + > .attachments { + align-self: stretch; + border-radius: 0; + margin: -10px -10px 10px; + max-height: none; + order: -1; + width: auto; + background-color: var(--bg_elements); + + .gallery-row { + max-height: none; + max-width: none; + align-items: center; + } + + .still-image img, + .attachment > video, + .attachment > img { + max-height: none; + width: 100%; + } + + .attachment:last-child { + max-height: none; + } + + .card-container { + border: unset; + border-radius: unset; + } + } + + .tweet-stat { + padding-top: unset; + } + + .quote { + margin-bottom: 5px; + margin-top: 5px; + } + + .replying-to { + margin: 0; + } + } + + .tweet-header { + align-items: flex-start; + display: flex; + gap: 0.75em; + margin-bottom: 0; + + .tweet-avatar { + img { + float: none; + height: 42px; + margin: 0; + width: 42px; + } + } + + .tweet-name-row { + flex: 1; + } + + .fullname-and-username { + flex-wrap: wrap; + } + + .fullname { + max-width: calc(100% - 18px); + } + + .verified-icon { + margin-left: 4px; + margin-top: 1px; + } + + .username { + display: block; + flex-basis: 100%; + margin-left: 0; + } + } +} + +@media (max-width: 900px) { + .timeline.media-grid-view { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .timeline.media-grid-view { + grid-template-columns: 1fr; + } + + .timeline.media-gallery-view { + padding: 8px 0; + + .gallery-masonry { + columns: 1; + column-gap: 0; + + &.masonry-active { + columns: unset; + } + } + } +} diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index b9d64bc..8019522 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -101,6 +101,7 @@ .avatar { &.round { border-radius: 50%; + user-select: none; -webkit-user-select: none; } @@ -204,6 +205,7 @@ .tweet-stats { margin-bottom: -3px; + user-select: none; -webkit-user-select: none; } @@ -236,6 +238,7 @@ left: 0; top: 0; position: absolute; + user-select: none; -webkit-user-select: none; &:hover { diff --git a/src/types.nim b/src/types.nim index 3f655b6..4f52886 100644 --- a/src/types.nim +++ b/src/types.nim @@ -123,6 +123,7 @@ type Query* = object kind*: QueryKind + view*: string text*: string filters*: seq[string] includes*: seq[string] diff --git a/src/views/general.nim b/src/views/general.nim index 9671c73..ac6c6e2 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=29") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=30") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: diff --git a/src/views/profile.nim b/src/views/profile.nim index ee3f71d..83d300a 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -102,17 +102,22 @@ proc renderProtected(username: string): VNode = proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = profile.tweets.query.fromUser = @[profile.user.username] + let + isGalleryView = profile.tweets.query.kind == media and + profile.tweets.query.view == "gallery" + viewClass = if isGalleryView: " media-only" else: "" - buildHtml(tdiv(class="profile-tabs")): - if not prefs.hideBanner: + buildHtml(tdiv(class=("profile-tabs" & viewClass))): + if not isGalleryView and not prefs.hideBanner: tdiv(class="profile-banner"): renderBanner(profile.user.banner) - let sticky = if prefs.stickyProfile: " sticky" else: "" - tdiv(class=("profile-tab" & sticky)): - renderUserCard(profile.user, prefs) - if profile.photoRail.len > 0: - renderPhotoRail(profile) + if not isGalleryView: + let sticky = if prefs.stickyProfile: " sticky" else: "" + tdiv(class=("profile-tab" & sticky)): + renderUserCard(profile.user, prefs) + if profile.photoRail.len > 0: + renderPhotoRail(profile) if profile.user.protected: renderProtected(profile.user.username) diff --git a/src/views/search.nim b/src/views/search.nim index a43008f..c79f3fe 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -39,6 +39,19 @@ proc renderProfileTabs*(query: Query; username: string): VNode = li(class=query.getTabClass(tweets)): a(href=(link & "/search")): text "Search" +proc renderMediaViewTabs*(query: Query; username: string): VNode = + let currentView = if query.view.len > 0: query.view else: "timeline" + let base = "/" & username & "/media?view=" + func cls(view: string): string = + if currentView == view: "tab-item active" else: "tab-item" + buildHtml(ul(class="tab media-view-tabs")): + li(class=cls("timeline")): + a(href=(base & "timeline")): text "Timeline" + li(class=cls("grid")): + a(href=(base & "grid")): text "Grid" + li(class=cls("gallery")): + a(href=(base & "gallery")): text "Gallery" + proc renderSearchTabs*(query: Query): VNode = var q = query buildHtml(ul(class="tab")): @@ -95,7 +108,10 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; text query.fromUser.join(" | ") if query.fromUser.len > 0: - renderProfileTabs(query, query.fromUser.join(",")) + if query.kind != media or query.view != "gallery": + renderProfileTabs(query, query.fromUser.join(",")) + if query.kind == media and query.fromUser.len == 1: + renderMediaViewTabs(query, query.fromUser[0]) if query.fromUser.len == 0 or query.kind == tweets: tdiv(class="timeline-header"): diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 2d7537f..f4899c8 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -5,6 +5,15 @@ import karax/[karaxdsl, vdom] import ".."/[types, query, formatters] import tweet, renderutils +proc timelineViewClass(query: Query): string = + if query.kind != media: + return "timeline" + + case query.view + of "grid": "timeline media-grid-view" + of "gallery": "timeline media-gallery-view" + else: "timeline" + proc getQuery(query: Query): string = if query.kind != posts: result = genQueryUrl(query) @@ -105,9 +114,22 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = else: renderNoMore() +proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] = + var retweets: seq[int64] + for thread in threads: + if thread.len == 1: + let tweet = thread[0] + let retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 + if retweetId in retweets or tweet.id in retweets or + tweet.pinned and prefs.hidePins: + continue + if retweetId != 0 and tweet.retweet.isSome: + retweets &= retweetId + result.add(thread) + proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = - buildHtml(tdiv(class="timeline")): + buildHtml(tdiv(class=results.query.timelineViewClass)): if not results.beginning: renderNewer(results.query, parseUri(path).path) @@ -121,23 +143,17 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; else: renderNoneFound() else: - var retweets: seq[int64] + let filtered = filterThreads(results.content, prefs) - for thread in results.content: - if thread.len == 1: - let - tweet = thread[0] - retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 - - if retweetId in retweets or tweet.id in retweets or - tweet.pinned and prefs.hidePins: - continue - - if retweetId != 0 and tweet.retweet.isSome: - retweets &= retweetId - renderTweet(tweet, prefs, path) - else: - renderThread(thread, prefs, path) + if results.query.view == "gallery": + tdiv(class=if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"): + for thread in filtered: + if thread.len == 1: renderTweet(thread[0], prefs, path) + else: renderThread(thread, prefs, path) + else: + for thread in filtered: + if thread.len == 1: renderTweet(thread[0], prefs, path) + else: renderThread(thread, prefs, path) var cursor = getSearchMaxId(results, path) if cursor.len > 0: diff --git a/tests/base.py b/tests/base.py index 81371d3..6d795e1 100644 --- a/tests/base.py +++ b/tests/base.py @@ -54,6 +54,13 @@ class Timeline(object): none = '.timeline-none' protected = '.timeline-protected' photo_rail = '.photo-rail-grid' + media_view_tabs = '.media-view-tabs' + media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]' + media_view_grid = '.media-view-tabs a[href$="media?view=grid"]' + media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]' + media_view_active = '.media-view-tabs .tab-item.active a' + grid_view = '.timeline.media-grid-view' + gallery_view = '.timeline.media-gallery-view' class Conversation(object): diff --git a/tests/test_timeline.py b/tests/test_timeline.py index 919aa70..65c598f 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -55,6 +55,28 @@ class TweetTest(BaseTestCase): self.assert_element_absent(Timeline.older) self.assert_element_absent(Timeline.end) + def test_media_view_tabs(self): + self.open_nitter('mobile_test/media') + self.assert_element_present(Timeline.media_view_tabs) + self.assert_text('Timeline', Timeline.media_view_timeline) + self.assert_text('Grid', Timeline.media_view_grid) + self.assert_text('Gallery', Timeline.media_view_gallery) + self.assert_text('Timeline', Timeline.media_view_active) + + def test_media_view_grid_tab(self): + self.open_nitter('mobile_test/media?view=grid') + self.assert_element_present(Timeline.grid_view) + self.assert_text('Grid', Timeline.media_view_active) + + def test_media_view_gallery_tab(self): + self.open_nitter('mobile_test/media?view=gallery') + self.assert_element_present(Timeline.gallery_view) + self.assert_text('Gallery', Timeline.media_view_active) + + def test_media_view_tabs_not_on_posts(self): + self.open_nitter('mobile_test') + self.assert_element_absent(Timeline.media_view_tabs) + #@parameterized.expand(photo_rail) #def test_photo_rail(self, username, images): #self.open_nitter(username) From 33bf2c2397785fceed1192b191c5f9b86b56330d Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 21 Mar 2026 06:52:51 +0100 Subject: [PATCH 206/309] Support tweet content disclosures (AI and ads) Fixes #1374 --- public/css/fontello.css | 17 +++++++----- public/fonts/fontello.eot | Bin 9820 -> 10072 bytes public/fonts/fontello.svg | 2 ++ public/fonts/fontello.ttf | Bin 9652 -> 9904 bytes public/fonts/fontello.woff | Bin 6152 -> 6320 bytes public/fonts/fontello.woff2 | Bin 5068 -> 5176 bytes src/consts.nim | 2 +- src/parser.nim | 48 ++++++++++++++++++++++++++++++--- src/parserutils.nim | 52 ++++++++++++++++++++++++++++++++++++ src/sass/tweet/_base.scss | 17 ++++++++++-- src/types.nim | 2 ++ src/views/tweet.nim | 20 +++++++++++--- 12 files changed, 143 insertions(+), 17 deletions(-) diff --git a/public/css/fontello.css b/public/css/fontello.css index 53a66a1..eb41de7 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,12 +1,12 @@ @font-face { font-family: "fontello"; - src: url("/fonts/fontello.eot?42791196"); + src: url("/fonts/fontello.eot?49059696"); src: - url("/fonts/fontello.eot?42791196#iefix") format("embedded-opentype"), - url("/fonts/fontello.woff2?42791196") format("woff2"), - url("/fonts/fontello.woff?42791196") format("woff"), - url("/fonts/fontello.ttf?42791196") format("truetype"), - url("/fonts/fontello.svg?42791196#fontello") format("svg"); + url("/fonts/fontello.eot?49059696#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?49059696") format("woff2"), + url("/fonts/fontello.woff?49059696") format("woff"), + url("/fonts/fontello.ttf?49059696") format("truetype"), + url("/fonts/fontello.svg?49059696#fontello") format("svg"); font-weight: normal; font-style: normal; } @@ -126,6 +126,11 @@ } /* '' */ +.icon-attention:before { + content: "\e812"; +} + +/* '' */ .icon-circle:before { content: "\f111"; } diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 2e05e0b254729c644c52a1291e36fa9d9c553a37..ed1dba857060b1a79481fb41b396899d62adf5c7 100644 GIT binary patch delta 688 zcmXw1T}TvB6h8OfnLFd`wyrz7GmC7Z7OR`(BnN4q+Av zz4&Bk6JigA4-vf-#)3xJLnv01Zw3(sVMI$rBoQcRc6OzEIp@3I_g&6C_s)Fme1D2} z*8$A>CwWBZnV(*q?3f!G96)VGKhlSSbc5YTotr_wrFa-p4fC*rwRS;3Z>rH_GG1L{1fdVbKtlP9k3;|B-8Eq6b+{3MP zx^;_5<;BuKyv2+|v$oiWrh*Hg7@z{ISL_D35$ksYJOIDTb_1|lena?V8)ZA&J5N{- zvw6F-ki*b}NV=BtQhpM4A9b2s;am5d#P#X9xrj?5nBtx||XR zJF!b#Yarn&;&zbQ5IsWFmS%qgspXQ#t?7rtkvg@xt|&;{qq?=OHWH4IN9J-aw_JFY z<|7L)6lqXau+E^Q5QU5KRaxYUQkIYvW;2(*FPWvZeSAPx6d4n-za-}*CW=h@`+&>% z8{}7=LF!wap6|I(-u?&5S)uq!U%!05`5!HxdYI@|mwMWoQCs$O^AU+l1Euew=hsvl kL-VH=q{M4g>M2)+VVd!@*`G-_M*DNoWIUE1)89G%0YEC7(EtDd delta 434 zcmccNcgKe5hMOlk%&0#V$r{4Iz_@{dfoVZNA=QhaFz z>A6XIKdTrR#Cd>Rp7g}x0-(461B2KXAkC4UQ<>({ti{H_z!CtIXUIrROwl{R8_&QX z1mrWAWdH@(o0t~^`5i#MN=9x;g_r$*pxHuefc%J@{AABWwJ4<)1_ogkpnzU(VnqR? zCEq+C-vr24$V<#kJv&!c4#)>OlId(gesRfxI&E{H15|+W=L(8a3+lF6Z)0Fkm6*JM z@wDa?21cMb14ADJGnl0Vq`83Jo5H}tkOUOxV&Gt4Vqkh9IC%<_BC8Gq5Ki93qzhEc zHu)pdze&ujlixG5aRPk{2BMSyG2c@9D9FG7!VJPdS_6U^L>L%8b3!nK=w=SqyWE?1 z3TiXSYz78wJipCX27cxjATyrsesUj1PZBX_6q{@z5-_<(#E@5jZ4Ns~2IOL~&8I}T UnSqkm#C0aKNaSvAmbl6X0Q_2KZ~y=R diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index ccc0436..19db3bd 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -42,6 +42,8 @@ + + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index b4dcf100b9c57f6507424657c3d0dc77de2ffd11..be34c4eb29f2d82cecf4fa1e586298f6c227dc50 100644 GIT binary patch delta 659 zcmXw0Ur19?9RAKZ_n&uj>fPPl2pekQpPWmU8mLG?L{Wl)P)1ON`)S>mTMy5u0sO{FB$F%tCypo=^DI^S;anH$tptS&4A?&iVy821K14sfnPOS z0h6$KD-ZyFS8N3=pg16X^h}G5^^J#ONO<5yEnRS6-~Q$>mnAFU6N2-j3RNh{kW!Gr zh0IeBjEUnAg4tk#9b<^>+EKqPWK^pPyv(aARx68zndxHtU*x&rWM>1 z>uU0MG;Ip167ZYWU=NFRvHQ+ap|DhXl~bmkKG)STO{a6lRGsOHtX5G&$Y!z3h_)b KQT$}R_x%I*ZIW{U delta 408 zcmdnsyTyBgLH(&n)({2;#tjS%Obe3BN)#BtKCwnHUMJcr~FbJ~%1@v+gD+(Ab`Q`!nCP2PIUSe+Q*}1ZE zKt9lsOlJ%7i%SmFX`2HbpaPUXS5TB%P`Ax`8v}!?#Ka$`HK#Bz0)-hE`WTqOEFB=t z1@zq%1{Q`Spg0!;2LlrW(+k1LZx|I>br^tfvJjK*W=AF-Moyp?!9a9!81pTqkAe&g zAj}{Pq%|OzL4<+fGbaQyh;B|`xy!v-P*9smX0zcY!+3t1uMGUmFFC&_^4~5|lLa(J4FCY- zRa?@Zzqo}43zN+N%-r%RAO06_SotJMgf@DUM z3|$fqkizS8ADG*Jjr}Ao_a74PrAK{T5Tu7Wk4akiKght$#eOcnPym1n2mp|UkUly# z_DT@s4+{zg0Ju|0J?}}Zi6o{4`UgTuSsq1_Mv^FsYEo@N6uCLO=Qxs~P$Ry5a;#K| zb-T9dM+cJHM*AZE*3wetGiOyX4F1bTEDt%HTqy$$EqcevpE*N2O;`x|`U4sDzT?&N zW9P-NYPb&`7hJ?V{jbu`Jz?Tj(q_K7Qxa=6C?Lv{PXIQoXsRExSj zP8KyTz+FBndxIY0k`BXf%CeD|@R-G#?lYR)L)ADyeI7X{4QlKBQhg{?0M$Q{^h)`I zn+Ct6m_#hlHZLwUL5$(GF(6r={*sbIml@;vI8_EFG6^$ z(BzW=?dJ2h)>xYmKb`J_ZtEqxx8H3xGvW-iks*7{qAnM_Lz8o~4;qV7LN+2#&h7V) z>s6iir)Zy~lvSJ5s~+F)7h5%oq^0fF6wHJmXt>N*AO54JHKo2XEzD+SC{G-<>S-8t zszG8CQK#Bmmgk@!g(-lx`m>%dR>;jntc10jRmrHx%(?q(iGKALXovjBgI@KJnf-{F&9<_qUYBpKj6@37a z`rMNcO|E{sOgc9MZ4arLZ=!~)qjtl8VhVfZP{5!B_lKsL?kG_|;9&^rx^v<(6k#wk*?yp``gehR}byVZx5dR3}hqom)RCZo8-)U0FMXeO%d)OiMLHsr+15)aZ5?1Cm>w$$Wh1>_SjycjB@=BSbF{&_L zT6qcC#BH@=@I?mr%qYaj=GEvo0s8cadupGq(0ceGF_Tt9IFu<%*BgzA!I0^huz?LI zk88Ti44E9_+*16#Lg0_%^ES&6QaBWR1he%n-brh3021uQ!b1y_hQ8@8-%9qPNoNIm z6jz9V4nN5H6M!^{%w^wqll7-d>+4IW|CZm%uaGY0R}mbAG502xerp+{P*=HC^gWP& zBEAo8q`dX?``STKW3cV?8f2_=ffU(M593BX92&Ot7v_E!ZtQ8irL@_wiNbuZJt*DZ z)HG+>{EQsWR(6+bd*tMLBx}UTE}AB$6>8nYFw=v9)UrXxz2^!bRPhVXBSqO`#T2}s z2o3`dmD-1Ea#q}>`1Df9HQyw8iGmpJC-C23LYoT%D-BW+>VEkiIT*48<8KPl&b_0? z{cDZ;!RW4f*1o=-^p*}HiZY#piVKoIg8%nqY-wq1;fPh^S8F>lLc{t3&jft3<_gZc zZMNFf4sv=wUWbGJ1P!u3!2ZD`+}wWOAX4tjuFNDW-~Bl&pq;h#rDQ)>J_lxEN6~?L zN|R$s31<4oLef26d--s)vYmHmcE)encuSgD>GMSZc{&*yz==$}a=SxT+z3nhIQk%* zm5E^zVO^?Bvn)wCxmQV)cFi=sSETvo%ex#W2^CXIxDzDsL#O^7+PAdP8a7gVnW+Qf z3xw&EyBiAdWyQ%plsxh?2BcreY<6mLykgoW^s^02#>5)tuWi#KQtMVnF>~vDBbEcg zwUM)HD{r>stKI->Fq3+;_e%reE5H}^MrMS=!2-+8Ia{xmH5xmsd*_g03$)SSwp;Pp zBeox7jo%o~#>hd$^tgE6Mg*vZG({GJ(VD_s81cxc=Aj)_tsxUHyts{m<;JG|14F2^ zWqB4(_E@u4fjmLT1VQ&Ae)~y9&g>tJ_zZo;J=`)|R|>NO_Mm2RlCfqz!};5G+pOR$ zT)Kc$3O?ZK3;nT_SAcu!&!QrH_mqH$_wZ^;6wC=#w*K3?#H>+gFk{%(ap4JElq9TLt3}BK zPrk+F-^uDUf(cO4QS)rF=sUjXYj(EgOuwm_#AvlVq)^8d9y7}zX@4Pul;y?Xi5}H5 z_dK#~>*cBlwtC>DBm@^y=!L`=I#g;#b`f-qAb&QmG>Z=z6c*ivft#5YT0qed= zo~VBTgK;kDCPABQ8gP2Hrj5BwX`MnQuyo6J{XIJ*#gz-ezy6U{}(Ajs_0WX;g#4cPWf0Vg8 zW`HDa57otl^x}j33eXQ*ixR$D-n9Dl`my6k#)9;BO}mHbmDQETO5(lPlKzr`%KNBb z?i=(_(@&=CcZH;N4CazGl#D)ucIlK?6!51$;QTLo{5yKfEyD6lD)A6aQE1tQ6+Y|xup!wlL-4~njd;ZFlEYdKghf92zOcdhtk zv^>+EWztU_y*^6InB{RKlCLZFKDDdQcbt7+F>Bqi6U@AFLO!|@)~y#J2vdGlGFMiW zzv`X;tlMz@>*3dDJ_5CbZa)ds2-}nZ_D#v~9)XnewKJnbdsA;Rys4xDT;fgoHC zrM8c5DC4Mg?q1IpUjrmMRLrwE!PXxCpm5TSpstxei)@`r8W?tBZjbY zJtt5-Z`oa#wvn$GOE7!w(T+y>rUU9Ov9_X+K((fT=;c{&ZM_3gB} z`~g;NHmd`RK#|8PKn)rutcVPKk(fUK!2gaI&dz|fK857pgGY-I&=t=pGN23M;!H~@ zm3aLJC6PiFB$qkT(@n`v1!z8G`S|}C=8M+@-Q91lulKGeWT_c7*?_{#fZQwUTckUq zdb+zCV$f(6Y795vfI*6j(v?cs&&4_C8pzMg9amV1-sNRv1h9NzpU9EolxDJ}M$%Bz zJOz-EMj!5aC(c|lRH{95j2w_Eyo4&EOX#J)MWaI__SCiI;m-~+B3MIG$~TWr6@xdml#;?uWUata52TY@B!Am4 zQD08=43z~cnOFLLBv-vz?6!cEj1*M|rB#JtprX8(ceue~iSUh287=GK#jUs3J>2vL g_r08o%x;@IUfEnC+#*jt*laV@o+-xWq;g0657N~JJ^%m! delta 3556 zcmXY!cRbbK|G@7hdtaB788@5c!?nrYB9T@0MaVTWE_s{TSs7VHvezYCdvwc1cBEuP z_O*#?es90W?>rvQ$2qU(>-9S4{CQ5@-B<}|zZN-i$@TG z=<+}y>Vto+8j-@}uFm#OAP_W#So0qdxHyi^)|DuU4oi%Ah`=Z!0Mhrao_>Ku`G+Xf ziCE~VT%CJ(IT8z51c|ZuKlrV9qde^ci3hV-5M!QyfaT~5p4odk6CJT1{G52vp`m>GFN2a&z5E7ULO`zum(&h74QDTAynhJco8~EZxj#NoTsl@2 zCRq+m&L`~`E)*0QUqTfI3e{T7kUJ)IWKcXQS*~hfp2##S^-%ydJT~HSkzJ+W^c`w= zQbhjQl*hF4p!28Wu$j7^bC9KgoK8al2@3^qMmSUI^XCAtBXRk%<&G-s0?!S|h3Z79 zZqQNDywwkXEuLgNSVCoNo|gDLqfAox*0;NGlc+LzlQm(O*iOP5n*ad; zk(xKy>=Q^nL~)ovRe3bfBL0*7xCqg>6ZRFfuGZA5E=ofYJR&m}_Vyr>T7-La^x#M? zkcra{n{E8@jJ45#A?a*$c z>*)qwIw04bM_4hPZ7g?oHasEei6wqn+e-v)P)_iI?C?YWmElsNy5oMbQqelp1E zT(9^jz)j}wvL2bf{uQ6xTV3&`?A{x7l90o|=y9e<_0vH9;}d z51F#{zxmyU>^bib&cN^Bg?3uP&Q_IM)N7gZ{nGm|k5Iv1V^Bp+p><@C#JOX>5)dgw7Sh8-^VQS}3dEU5jUM4ao()es%zXHlN`|zO@ zatvi3>+jKx&BpjG&+0g&Ah3|iG$KUP5izHqy*4V$M(HKwS=3#-s_#lM%m_txr@^P# z!MG;$W$!R(k;|3`h&ymf{VbWGhj3Q6wzak1R)h@)RC&m%=wzs(NIz#`vW=XCCr&JX zfbLAKTHqx^xxPS@3y))8Ds0p|=PmVQm8BEC+l@)p<)EA$WOk^WA;a$Y-gp-2`Yz5j z_fG9uIgO7R+K#Yq-eX|*XPetb*{ZT4ZV&8EunY3@dlr}t%vpz)hI)nM@-<5U-U|yp z7lK*CNf7(z&ySuLCj5wtNc^tWid$+#BUH#H} z<@gN6(K2b6OdDNO{WWAFyXd`IkEriQHV0aTDA)2zORtgHL&+$y3Aaff>_x$X&rw3J zLRN&b$x#-77DLwPC&7u|aT~9MxjydnbW|$5yHUhogcFv9`5X9$wKk@pkVTUbYVn-< zEM=t)dK6zMqVz5gyv#gb#~~P>=kJsqGra6G9e#Mdk%&z2H3dNRdT9 zRpPLOFd<@#alDSNyA>( zJ`2A_71>?uj>;-DEW2t{HUq9qPh1TSl6JUhFzcf%IkA*Oo}ji%O8)9D>v=OEE5b5T z&r5O6vhYQIKWB$nbz+ky>N{rfEnePJAawlUL_Qt8_9hnuGruc&SeLYVdsp#`QzQ6} zF^&(YojNWzY3v+%HXq0LQ8ia-F}wa#`Kip7{|Q>D!sy4=k;?mNW#{Co-+QKoxm=oE zAE(fKk;V=3 z#gsvY0qNkc-jgNnF?Jn>dC&!_Vl{buZD^`v&&51+vA*8sO z7S!&ajHN(Y0p1o1JYt1EWm9HVpvk5{Ht2j~d{0`TpjbdcdkfY_;NqMYOv4@NYna30 z^Ry5iyvv4I7$_Kbz$q^ahj?Z!hY8>urH+HQ?0_DQ>9kvsYo4%(P!`*tw1hQv}D%G zYmKkk1EXU}i^)llZHizL`V)^5k*t1~19T>TYJhmGfx3|+bBaN(o7`iyy5A=F7{C5* z){?%_q_kUnc^ZlL=wWzABQaq$w?Aj_4yi{?n>3#^?Oc1~m~*7t%Vtc^c1bgimt z=P_RM2NQk_#e=P$-o=7~`anWNwNyP876U6ngxF*2sc2}5A{hJv2Y0rD?73$FpPZ^A z*+b7$*1s>vb<-bI^T2tv`xW8!im8aeLh>hNCY0&f(;PbYnoxm5d$&8#%bLFYf#FET>y00Cdl)e;8fi}Qgw zH}_ei1`7^hxO1oU<%6aJ9W>^6VI1GH?#r1!pIh)V2#@`AdCY<$SD0L}!c8vRELJ-_ zzaFyWLZ_A0g_RYn%bdcq1SqX+Bfsyo+NjGu3A1dcZz-=?qRXN={4#z)t+?gS&_Y>N zQ`I@_&)39>HJwlf0Nw8IuFdGDFrSaq?Z<}>;>b21cCtqSB;4eZN6g$}xkyJpV$OjEn-cB_PdVv>{_hblZf6Ht9C~TVy+rWrl-&KV3luM*ID7F& zIq~x97~oF;`%0t?_H7_6}BiW@0k!Kj1BY&ynirdWTD8oD*>+N7_UFp

OJUFX8n;VSf47xaEVZwjhcXaLFB8~h@yvygCk9Z1+`~4Tl$w|5x-cb aG&glycby1GH0vS_BtQ%RfzqYeLjDI{;+7Zy diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index 7efdfbdbc78248f820089a97017c306e49c23955..cfe8dfb8c45eda69650856a8a6f5f37cd2a39e6e 100644 GIT binary patch literal 5176 zcmV-86vyj#Pew8T0RR9102DX?4*&oF04A^i02AK;0RR9100000000000000000000 z0000SR0d!Gg)RsV37iZO2nwB4nI{V{00A}vBm-OoAO(d@2Z3S?fgBr4BUPtD*f=l( z_7xe0bZRi7WdDCi;Kop}Ppapk1~Vi%$#Cw^A&z7pK5nQcm0HZ%E zaVc=Kf^$1sR{;bJ(9ODrFy7Ln=EgX(uFJfAj`iz;CSzVgE! zZGa9?ri6VCNkQqn=c0B|X11%I_Ao!Qy*@Zj+&*z3sklyax-GufEVW6*Y!lMW2)XcW zQ4!K|gb>gF{PfV2bR{r_V|fK?gPUw^mm91l@E4P5It*He)+29XnAM4emEW z-o&PMst59;7(8z`?Zew%+d*rA9*DKu?%J||4m=QQOCg{D6XyPJEw%qoh}{WOVquzU zc5ZGK-u~IW3n1>Q0);>oNGMc!k%b^_0Z7#n8WW`|WUVgzKYvVp=l;7^ol>_(+8RI- z_n1zwaNYL3X>>L1K}_p-b9Hd49kAS0;6sKluxs3cKIrjmk6N-C*QNu5d>RGB7J(nBS^ zRMJN!{ZukQC4*ElEd6cE@bvd2BhtU}(j&m^7r}o9AizWDN0E1DFn03};qFIZYmw6_I&B|Lf$MM(+d#Wg17hPMK7-)# z5NOD|HBt-zN091AJLxFL=6T@$^^;hc%-*ba? z))dqRWyIPcLm1u5;N*-*OClG;azv-3UND}5Rvr`f{X=Uspf)ukOG{vO6cr%Elk}Um zky_Hpu&9j&szsQzGU427_2Tc19yW=xv<)_sH1EU8ouCssc=sg~^4*KK(K|YO zfm^y4-hHkm8EXREV7qxd1_GOv10@Xc3;dQT0?7 zX*4%4lXK_hy!XO`w=hssyn)D%xOgi%0u(4ZD})R|#4yB+gM<-C84no~AZH>JOoEch z`4p=9ArbPsFF~aOeH$sqAXp#nq7VJHJKHAty}}Eakw3$H~#JdOfdo3d#=zGMsXg6X_%%z++Kc4r1fHgs4q zlt<9rtGA^qWlIY&`(+2RAw$+P0qQ@NK@=bGb zWPNp(uy0PdF3h)ks>S(-jspNCAtezX6q1RHCi|%D95gE?veE3ZEgYswl~g%`dCF|z z{pn;%b`uUsSPtfpRLcv|pJZzkO4CyJQ3nZu`>ISQI`x7y2w|!fp1CgeBFqM!soE^P ztE?iBN(XtxC<+H)Uz7}vnxrTdkXk9NeTMf8pI|nm$^moYX6+<;ST^O+aioSdL1h%o7)yugDqORwxgt( zyZ^K!6t6dVb;c@BOS*om8B{6-@^RvjVPwWJ$VRNnn$C1Q@y~W0n^Di_(OOM?(U-tM zOs(-{a2T7a6!vwD%^sNzffZ}fa3uvw+q5dB*Jd>Hd<)|=G`vhIT}j%06-DWB5AANw z|HROOg*f6AcJ_v@7;7tOHnIrU@?pKB5W0TjWt!EJ5TptJc*838`Q#Q3ZsVM-A-_|X zbMtfy1@tmzSFCS?{Uz@Fg0int$)IT)MNRI)n8(~XuI`})87S^c<3SM#52c+)c!qjh z0r8}Wgs0NgGaN!mxBa|K8ZSync$u{Gs-t61g+0oZ~l0K01k<|Hw=e0g_ z%$Mp$d`+nPjU{^NI|l%Dtrf=TT2KL92Pz=E5y3b|6gus7la(=ooFY4!#H#_^%0K(S zN0C7)Gl*(QBuH&&Os}28(E)^eqz5?0C7BeHWwtDq)v{T3%aM!IaXD_s<9HpP+y0C2 zuin&NAS|||YyThA8XKeUAa&r@Mzb^Z3l|DP-7b@Eag_8H)bhSCAE#A8`-GGI&yFxO zX$NEilQr;;jOYgIR~s|KB-AEU(QSc!wOY|{$$j+^P2Qpx@mpvE*3~R>?x0cqGBmfWV z2iUHi_)duZ;_1VJve>8#=~XV=VbPg;S-7r~KtHGZcQ+^#l!-KM)LB$6EiDtB(;8Iw zz;f9$vr%`I%KtzAe*g31AFVA#P!4$FeLn6IMHpWEIuhUh+#4jdQPDmtW1i`7@BY#i zO~r9xqB>=x$XZ;A)PoBiWoz}l0D;i%!)od$4~0cqfvc zc?30ez4yX}T9dAHS|S3`1L?gd+BuQu%6nfFTcNZFDH8yZhFE88vy2*rfdSV_#0d$V zHz#{HXcnW&LMXG)5z@`#+M{>(hTVDu!wb6=bKgFc?K2c*FEU7JDK@s!8kc-q0Swwb zOR?rHeoCrGdJp0I=^o07j$VN69j|R&p08+qSxRumJB&B>4DT4;D15Qw1;Zn=og1)+ z88)6mVXY|xNzLe3{xq%IeykvU zl)Bn$_>Ln0vIy4u>E{^PKEE?x3O%GFsKf}Utu0N-l68mi&=1EnuhW>c4hpgZBy894 zAd64#^}M2eCrX&#e1}Sn_8YD-5l?aZ!f1SSv;=Mz!_m1h^m8NJ8k03d8?#HSo1&0h zTKyv{k6s7=gh2PzkP*4la%trd$GCw0oaP=B^F(pnNRO#&a)N&lL``a$%Hi+F&r?-1 zMMX1JV`JQqF);DJ!~Xyai}?7l5hU6p5_v7JnyFg2FoGBD52_sIom3V)-a>^ka(B(o zz2IL56-WU2BO1P*=s73EN#vk#nsZP(h@AF1xXPButj9r}mBVwxcs0v)Ve7EWW%|#z zYGmYc9T>p(OH`Qg+k%=HD}$edWVf*)s2GYVju{4m+-3SSl^E~~60ik})o%G{{Bq!} z+r%PMQ?n%v7zDElql{Z&@32@T4qQd`3T&|Dt4-b@mBCNFK&2`Gdn>i27FNpvGwSz% z58eqrRu^6P961p06a5JgM{n^lCu~zcRHfy^^xitOaCXLghN{+?uT7TJWZzY;GuoM>(+pev*RBpZ2W14soz8-|*ieS3zp-#=#Yjc(xwq2_+ z;p@WJgK=S^Jh~22Y^WY@U<**xVqsl=uzO(ar8-r>|M~d{$Ao6-4z+zJ>cD)nh!*jD zt*EPOOGCr9t`4@Ti&oJuETWHKpHStTF8|_tQytBzI6%+bN%x_imo8UWz%n;OjBYKF znX` zQ&w&XHUC_aHWHD4Uk87`xf8n+XY3jgKdN`Y(?j33$*&{_kC86mR#>n^W9w*QD;|f& zhdHhF425B9)%)KoS1f5sykyxfhc2b@jK(}Gf^-tIL(yKJ6FD;Xg^2PsCf+c%pkSF; zd4Ldft%{piJn44;_9Fb2LEE**d$goHk+L{NNEh^ob3teb*xfZPj@QNFaC69vW)wmfVAzlVnJIVAANSqk zhBTdLA&;kj3eytG_93S{M2a*7ZjRkb;DoOu7m}i+IP?jJJKOWrHEH78BFmmP#F|f` zR@NwQ?Q8UUt*+8*WhcqWh38I>3aPkyDJpa`mx(WKRaq8Ah?Z4tQ)gxGDe@?NLrEO? zp6l3_QOk_dQI;^0vSmR!HzdDylLRNGLVCUJ<=0$MnIScAOssn#=l{|^E*+%ABm|cKy(@u&2U?7Z3-OwFL9vc_B zFDiC$jc$GyMVSp1tdo<;OxI4TWH*PdBV9ay$=$TQX4Chxxc{@hYqK;FT(gHb3@nb) zysx(>r^uLJfISIwg-F2!9Fi_pR~8#|qbg(G7*n?RtR2R!6mTim85WaAhLWZHg7&ck z-}c;cj+87DOUCB+Qd_aLKR6U2WvLRLrZ}I*A-h z%rw<3tR%rmFxhwazm$KZb}#4uG%vY^4G?Y~+cCN6FbR*uEWoa2Hjqs7o2a<16_o~WM=ewzT9A4(y%@Bitioq>?)3+br zv3zsi3I9udUIF~^+t2q-X6*j$u8T#U2$UF5E6?O+5eq%=Z*#3|7?nhlz0lGPBR&yS z;Q+Sa&J_I@+24hS#X=a7YPZuz*%#T@cK;TV?D*4qJZmSToI}vfGFpiDdf9?m}@U;MwM8DzgnqSjBeM4t6VAf?S(x7&Nyt zbf%WGDmg7%;k5omD{bZih0spR#T{p%g`%Yz-L0te{6|I06-vq~RH~-B8fvOXz3NlH zKpBW2qDUf(B14i3ikX~X%;Y)aE4*FI<@uPWy%k6IOEM}yDtrIobvCQRanJl<$h37a&X4ASw zf`5nC2HF3ANZ@3MfP1KVGYAZU)vGd94YW1Xz$W4~4r5p$Ai{K|3$xzTeGzLoj)RT1 zCf|?7{*IH*%1-<1=f}xMq~T{qNADT=^6@=-qd|jBI!%ZjswTw#kr9&2LdDyBtDc$n zc2}Pa%n^{jj|{*%05HkX5ru#lUJ?OsYRGk{)9xf$29k^kn*&af)jl9@18ocN_UrN% z*|}4950wBSTmZoHX*0Q~F08;;et@(AIzX8c_BkX4rSqPP+C`b!u6o+z0lHZjgxmkp z;A&E92vhpzYNA_%-1d-rkjR8vTH;5%Z~8i!(yH!+oCLpNqO?b8#r`3;!!ojsz&4oN zUocIFh1Q|Q+ZO{=5I*TA%ja>Z<*<2$Dl63`%yc zr+T{6Ro#Q1`p;s|EX@Dh6T*Rn6`)Y4{#i*hyF42JP_cx^$Hv9D2pJ=M7Tv6Pm~^Ru z2fa5xI{!862BmXcZT_q;{38+2U;!(!=K2jg=)L*q-M2D|000Edo9pHqT1P3X6@maL zvzhj&end}`z#g<8fX9R1Uw^Pr1dQbL{_-uS`cLr73oe^g@ z(W~e2`bi?d>O6{75xf;{jsb~k`7z#edfDuBs4cvn))liTdPhX>#Q6Vt7?@btIJkKD z2vQ&viGi}h)NU52n;ELw^iU#EOH9-f7quirElHx5WKl~BQA-WN+Mq9|Q~aIQkV%CQb8LxqZFV3X1H`Ms{DK z6cFc{sdQKDBerT*sp#JXnMm`g3}orFG8x=hGqz3JQHt^0VIYpfF>o7zv#Pdp-$*v9 z9i8PgGL(#br+N|Dsl6%FNY3HK5U^) z$7H-M)4U2`K0~Sr*aS2Bs>6gZdOMHfQzn%}E~9cpcc*UQU=~_)NZ9iatkIBNZ$zP! zz_BqYK$s`#HD!@n(nXlp#yHtLN?H-r55sQ!kH!sKpn3pkS|U$zbMgo7fV3YBgEea7 z1MBcnNZSGLufz@kUn7FgccD{l$b3Ys;1(iwNPr`ewsOj(*^~vzB+YBB_AY7L0Pnws z!oIul7H&(YD+)L6hWB6U1!KJxo-sm!vCcO?$cG1uN2Ul6;=cGLh&_Q*Do`JWBD-3c z`{t9T8H-h`cf3Vta70q*Y)p*!93Xg#XrQT9^moJpvZP&*z*dVPG(WZ&GljN;uYth4 z^zh8%8j!uDlg`YnjLt00c<+U$=;XjsqMpR(G=DQX5|l(3I=fQJkUES|mr?35Mt!Ey zfN>f!okq-{F*Dck*A}Y!J;L+*uR)~(KMs^kgxP)wniK{1C`mLcoLN-N1kCrd}CK}VlT=l(lVWa_ZOFO|SdReOKapbx)x~wkE?Eq6eD*MVC`6`i zOCX^XIQDFK>4pbz|ZDK36H~3{#FBPSQ%YR~Nbum8`2ZUfl>w8HOxypFQO5 zZA)5%O)cHsQO3+Yd(u^k_gZ%MoLQcfJ^cSejy> zGsu{R!DJW<50%*pkTVT~3#xcHAb_ZSu%KW%hS0)9T?Q62v4n}GOeV_?GRx7=3iPuQ z{iec0V>PZu<7`lCA?UCK$?BqM^&nY&G_3)e)(}l=gr+q{b9{nAr@dghQh;-O2Hwmi z?i3*SY%>I2vJkMA0<`L6v;Dt{D*Obolh9 z4MWah>1=Qogrk>_SawrK7z%W75irZ3Ln$H#*!J|ampa3&V?T<%swxHu({BtdiokRx z&rQ2$K;nu;`-(NvQET|3^`f?k6v0oQYZ@-_Yu9eLU-Y0bgUA4I6TmNBKQeDpLUvH% zmi@7Cfk=htrQWu*yL#CwtpjSlw?$kj0O+Q0de~FrXZRNB$2J~VZ9(R5dutbl^g3#| zvQFc3eyc^z{vy%!!UWxi_=!=YO5YN)9s+tk5YflD4dl{x=TkzSq}=J^c%bz?zAe9j6o3yau1$fgSj zSqK#3h3{n+WkePP#k+XG6dP8QVrbf+uvPua37f(u9Y-f?g zE9I%1O@*IDX7`t{`Bm9;;ZP}`%cTE*{$2d@vE|< znQW|Sd{ZGGB_kU%xQg(1+s8q?9>YLv#6g&=svCfC)ll`@y%2GKLpOwzCH}*t--MP* zg=Ch^(*?u_dmlln0}TQ9RZLB6m986AepWaV}9_(ayI2J2~B2{>j>I zEh@;*l1cSPj?{vZg33xj!S{`cs~^5&Nl19b$kWhjL|Z~wD{R8D`3?#!k5$~W-|8GX z%IOPV>W1r*>#E-MS=W7*Z`?6cnO&)z{~RCpgLrkh=x~T|B;z$M@dI`MD+k7b%cel&7=^+gm_( z47dh#B+p<6$w$%+wvZ<5LG~Wpts%d8r&$6DOnXd=u@1w2zU{WOT+WksqBsQbv?*mS zMd&ft$ijIr& zeJgG4RgCK^KMu2WF3_hygD(+=ri5I8!FXVV9&|`oZv?Cj`|k9?*56L=q7lW01NO5c zd;&8Fi*`DS%R+~;B<(P^OHF;fJvu#Za9U?7h}mnzw>qu0&18yiwE8ItQC_mDu%E(S zkbxX0i3;#$L~-=v7sV1qI7h73|_h zl1abBxN=;v4=Hm;hH~+}mb6HhFIZN`K<1&Wr3{XifYs6Fs^1#qX!ERj2X(NE57FNG zBcdFeVT~y#kt(gqXqbv*Z21Vi3_~PYRX|`&m{2JuxfFX5UBzCM;=lu=Si?Vl=)$qGW}TbE*O{Z&-{-~aue(LZJ7_eSr1eEu)~ zkBlZHf<3h?1m3 zpHo6xyyEXmOWc>=C$Tkg4HGZFR@nm<=5qQ)4k^(RqO5(m3|%a}ZQ7Wjhqp1N#MzYQ zqj_qjS?_<3jSiLsTms5D%nvr2f#*aav;o7~iWH>Wee$^9 zJG$xjXEV;nwBL2pqE_uePPt8r)CM=lP7^rcH;@ZM$xzz!@`LTI*|aM9y;$ljFL@Ex z{Sj1UMR~fftJkZBO0UXRl9LP1tsWIpvAe{D4l`-Lc|tZAbUFwVCTC@l55{rHXxQn0 zNxg0oM`0j^XJ&>g6+CIx5=UJVlIemAGxoZ`j$+u>0X}m1_I@f-=v8m3{M+0e8xizA zq28YEDvU8h3A5OM5m=li7jk89Auw?Rky7j?A{{10+B{G!w@uki#aNERFfjPYR&4-8 zCT<{?MRK?7EljfAO7Ta0{ajb;D3-y4IVK<5owz%w&r~Z9Ndp>JbW)(QH_6{gkqhP1 zm2wn09xY`QvZ6%*J0BlMlL)r1T|F$XJ9!dMLyM1Z)l4GsEX+0_+_aOX1FAp74U4$DlDmLOci+6rHwJHbw9;cew zbkjsoD9!tNdvc15`3CGsnJYy%Cg2`t&sUb`>+O0r?rO%^y{Tm#8K<3rOLK!^F)fjy zqLg1!G%$(f>aYy#duq>D)=L7e@~kN5Z4`%w`%d>yAbdO5ebTpO&+2IqZXS1Ha?_(E zJW@sk+szJd+&o%Mh@Om{F5iCd>S}t_j+*Ll=}4W;oYX_2MH-oeg3+j?zmPs~Qq#07 zygb*7Rx@M}%A$!uLDleey}ob|zw(#=H+-)F{^dWp9~`yww{t@*p!a|h18V0v&;erp z0bOp?F2S%Q3ii%NA3(e!Y7E2#I^V4;k+U86)R<30d+aRzO#5Zo-p*e;pqHIYRt)26 zV!)U%;)#uS^mfx*KZxI0p`~tNl_*)d$|zW&of1tQTx?jC=wgS?TpV82U^cB|cQKe} zaTFDBBX5FLVu1WrMz6+e=VD0*7e}iS!*oMuE>2{vstHX@HNt}ckOUp z9F6mf3YD9Sdivi3?#KRkbuVx|{??hBn61~ngrhbsv$ZHa{gmHc#slkpuA}QMRhQLU z#!ZrBIa0-l5-lA7$qfp?f*EQ|uOsAIKoZ4~VF9xDyi02HKifO#6sl7@*h=ENWV9taY1@J*4RS*uzvDqF-lWDYtux${@ zV35kta{)Z6s{zSy9lO#jDlk!qPA4K;jx#^DCR&ftyAlmP>0u&rg_5!gm8#TGm%7!X zUiE1JN(?Z>2xCkzh2*ypB+ro`xtnAP7m*-2V3=^kFySAPEuAv5rAtSq@B}g8qGG}~ z!!IV$-S?fuF#CEO({I1wRzH4Q%kRAFF1zif{5>B`_>!1#=Q%2H-8cN||M*ks$-U@t z**o9 0 and parsedMedia.len == mediaEntities.len: result.media = parsedMedia @@ -409,7 +416,7 @@ proc parseGraphTweet(js: JsonNode): Tweet = else: discard - if not js.hasKey("legacy"): + if "legacy" notin js and "rest_id" notin js: return Tweet() var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) @@ -432,8 +439,41 @@ proc parseGraphTweet(js: JsonNode): Tweet = with restId, js{"reply_to_results", "rest_id"}: replyId = restId.getId - result = parseTweet(js{"legacy"}, jsCard, replyId) - result.id = js{"rest_id"}.getId + if "details" in js: + result = Tweet( + id: js{"rest_id"}.getId, + available: true, + text: js{"details", "full_text"}.getStr, + time: js{"details", "created_at_ms"}.getTimeFromMs, + replyId: js{"reply_to_results", "rest_id"}.getId, + isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool, + isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool, + stats: TweetStats( + replies: js{"counts", "reply_count"}.getInt, + retweets: js{"counts", "retweet_count"}.getInt, + likes: js{"counts", "favorite_count"}.getInt, + ) + ) + + if jsCard.kind != JNull: + let name = jsCard{"name"}.getStr + if "poll" in name: + if "image" in name: + result.media.addMedia(Photo( + url: jsCard{"binding_values", "image_large"}.getImageVal + )) + + result.poll = some parsePoll(jsCard) + elif name == "amplify": + result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) + else: + result.card = some parseCard(jsCard, js{"url_entities"}) + + result.expandTweetEntitiesV2(js) + else: + result = parseTweet(js{"legacy"}, jsCard, replyId) + result.id = js{"rest_id"}.getId + result.user = parseGraphUser(js{"core"}) if result.reply.len == 0: diff --git a/src/parserutils.nim b/src/parserutils.nim index ea37468..518724e 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -320,6 +320,58 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) +proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; + hasRedundantLink=false) = + let hasCard = tweet.card.isSome + + var replacements = newSeq[ReplaceSlice]() + + with urls, js{"url_entities"}: + for u in urls: + let urlStr = u["url"].getStr + if urlStr.len == 0 or urlStr notin text: + continue + + replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) + + if hasCard and u{"url"}.getStr == get(tweet.card).url: + get(tweet.card).url = u.getExpandedUrl + + with hashtags, js{"details", "hashtag_entities"}: + for hashtag in hashtags: + replacements.extractHashtags(hashtag) + + with cashtags, js{"details", "cashtag_entities"}: + for cashtag in cashtags: + replacements.extractHashtags(cashtag) + + with mentions, js{"mention_entities"}: + for mention in mentions: + let + name = mention{"screen_name"}.getStr + slice = mention.extractSlice + idx = tweet.reply.find(name) + + if slice.a >= textSlice.a: + replacements.add ReplaceSlice(kind: rkMention, slice: slice, + url: "/" & name, display: mention["name"].getStr) + elif idx == -1 and tweet.replyId != 0: + tweet.reply.add name + + replacements.deduplicate + replacements.sort(cmp) + + tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) + +proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) = + let + textRange = js{"details", "display_text_range"} + textSlice = textRange{0}.getInt .. textRange{1}.getInt + hasQuote = "quoted_tweet_results" in js + hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails + + tweet.expandTextEntitiesV2(js, tweet.text, textSlice, hasQuote or hasJobCard) + proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entity_set"} diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 8019522..5bf5a2c 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -80,8 +80,8 @@ } .tweet-published { - margin-top: 10px; - margin-bottom: 3px; + margin-top: 6px; + margin-bottom: 0px; color: var(--grey); pointer-events: all; } @@ -292,3 +292,16 @@ padding: 10px 10px; padding-top: 6px; } + +.disclosures { + display: flex; + flex-direction: column; + color: var(--grey); + font-size: 14px; + margin-top: 4px; + margin-bottom: -2px; + + .icon-attention { + margin-right: -3px; + } +} diff --git a/src/types.nim b/src/types.nim index 4f52886..c2aca50 100644 --- a/src/types.nim +++ b/src/types.nim @@ -240,6 +240,8 @@ type media*: MediaEntities history*: seq[int64] note*: string + isAd*: bool + isAI*: bool Tweets* = seq[Tweet] diff --git a/src/views/tweet.nim b/src/views/tweet.nim index b1f805c..aae56e9 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -257,10 +257,6 @@ proc renderLatestPost(username: string; id: int64): VNode = a(href=getLink(id, username)): text "See the latest post" -proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = - buildHtml(tdiv(class="quote-media-container")): - renderMedia(quote.media, prefs, path) - proc renderCommunityNote(note: string; prefs: Prefs): VNode = buildHtml(tdiv(class="community-note")): tdiv(class="community-note-header"): @@ -269,6 +265,10 @@ proc renderCommunityNote(note: string; prefs: Prefs): VNode = tdiv(class="community-note-text", dir="auto"): verbatim replaceUrls(note, prefs) +proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="quote-media-container")): + renderMedia(quote.media, prefs, path) + proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if not quote.available: return buildHtml(tdiv(class="quote unavailable")): @@ -315,6 +315,15 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="quote-latest"): text "There's a new version of this post" +proc renderDisclosures*(tweet: Tweet): VNode = + buildHtml(tdiv(class="disclosures")): + if tweet.isAI: + span(data-disclosure="ai"): + icon "attention", "Made with AI" + if tweet.isAd: + span(data-disclosure="ad"): + icon "attention", "Paid partnership (ad)" + proc renderLocation*(tweet: Tweet): string = let (place, url) = tweet.getLocation() if place.len == 0: return @@ -391,6 +400,9 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.note.len > 0 and not prefs.hideCommunityNotes: renderCommunityNote(tweet.note, prefs) + if tweet.isAI or tweet.isAd: + renderDisclosures(tweet) + let hasEdits = tweet.history.len > 1 isLatest = hasEdits and tweet.id == max(tweet.history) From b726767df438bb87dcba43d4493fdb787c5f69a0 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 21 Mar 2026 06:55:55 +0100 Subject: [PATCH 207/309] Bump css --- src/views/general.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/general.nim b/src/views/general.nim index ac6c6e2..7bad908 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=30") - link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=31") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) From b6ccea0c7a0eb46b56a4f9fa87636f3c981609f6 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 21 Mar 2026 08:30:07 +0100 Subject: [PATCH 208/309] Add configurable retry logic Fixes #1382 --- nitter.example.conf | 2 ++ src/apiutils.nim | 28 ++++++++++++++++++++++------ src/config.nim | 4 +++- src/nitter.nim | 2 ++ src/types.nim | 2 ++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/nitter.example.conf b/nitter.example.conf index 5263ebf..a58c63c 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -34,6 +34,8 @@ proxyAuth = "" apiProxy = "" # nitter-proxy host, e.g. localhost:7000 disableTid = false # enable this if cookie-based auth is failing maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions +maxRetries = 1 # max number of retries on rate limit errors +retryDelayMs = 150 # delay in ms between retries # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/src/apiutils.nim b/src/apiutils.nim index 42664cf..fe0479a 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -10,14 +10,22 @@ const rlLimit = "x-rate-limit-limit" errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} -var +var pool: HttpPool disableTid: bool apiProxy: string + maxRetries: int + retryDelayMs: int proc setDisableTid*(disable: bool) = disableTid = disable +proc setMaxRetries*(n: int) = + maxRetries = n + +proc setRetryDelayMs*(ms: int) = + retryDelayMs = ms + proc setApiProxy*(url: string) = apiProxy = "" if url.len > 0: @@ -120,6 +128,10 @@ template fetchImpl(result, fetchBody) {.dirty.} = badClient = true raise newException(BadClientError, "Bad client") + if resp.status == $Http404 and result.len == 0: + echo "[sessions] transient 404 (empty body), retrying: ", url.path + raise rateLimitError() + if resp.headers.hasKey(rlRemaining): let remaining = parseInt(resp.headers[rlRemaining]) @@ -165,11 +177,15 @@ template fetchImpl(result, fetchBody) {.dirty.} = release(session) template retry(bod) = - try: - bod - except RateLimitError: - echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..." - bod + for i in 0 ..< maxRetries: + try: + bod + break + except RateLimitError: + echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, + " request (", i, "/", maxRetries, ")..." + if retryDelayMs > 0: + await sleepAsync(retryDelayMs) proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = retry: diff --git a/src/config.nim b/src/config.nim index 343b301..800224f 100644 --- a/src/config.nim +++ b/src/config.nim @@ -49,7 +49,9 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = proxyAuth: cfg.get("Config", "proxyAuth", ""), apiProxy: cfg.get("Config", "apiProxy", ""), disableTid: cfg.get("Config", "disableTid", false), - maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2) + maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2), + maxRetries: cfg.get("Config", "maxRetries", 1), + retryDelayMs: cfg.get("Config", "retryDelayMs", 150) ) return (conf, cfg) diff --git a/src/nitter.nim b/src/nitter.nim index ec2decf..ba72818 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -40,6 +40,8 @@ setHttpProxy(cfg.proxy, cfg.proxyAuth) setApiProxy(cfg.apiProxy) setDisableTid(cfg.disableTid) setMaxConcurrentReqs(cfg.maxConcurrentReqs) +setMaxRetries(cfg.maxRetries) +setRetryDelayMs(cfg.retryDelayMs) initAboutPage(cfg.staticDir) waitFor initRedisPool(cfg) diff --git a/src/types.nim b/src/types.nim index c2aca50..7f98b9d 100644 --- a/src/types.nim +++ b/src/types.nim @@ -310,6 +310,8 @@ type apiProxy*: string disableTid*: bool maxConcurrentReqs*: int + maxRetries*: int + retryDelayMs*: int rssCacheTime*: int listCacheTime*: int From fea6f59005e7178abffb65b26149f18267f84093 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 21 Mar 2026 11:28:57 +0100 Subject: [PATCH 209/309] Fix mobile gallery and grid, add size preference Fixes #1379 --- public/js/infiniteScroll.js | 9 ++++++++- src/prefs_impl.nim | 4 ++++ src/sass/timeline.scss | 29 +++++++++-------------------- src/views/general.nim | 2 +- src/views/renderutils.nim | 7 +++++++ src/views/timeline.nim | 12 +++++++----- src/views/tweet.nim | 28 +++++++++++++++------------- 7 files changed, 51 insertions(+), 40 deletions(-) diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index 1ea35d1..f79912f 100644 --- a/public/js/infiniteScroll.js +++ b/public/js/infiniteScroll.js @@ -27,6 +27,13 @@ const GAP = 10; class Masonry { constructor(container) { this.container = container; + const colSizes = { + small: w => Math.max(130, w * 0.11), + medium: w => Math.max(190, Math.min(350, w * 0.22)), + large: w => Math.max(350, Math.min(480, w * 0.22)), + }; + const size = container.dataset.colSize || "medium"; + this._targetWidth = colSizes[size] || colSizes.medium; this.colHeights = []; this.colCounts = []; this.colCount = 0; @@ -90,8 +97,8 @@ class Masonry { } _rebuild() { - const n = Math.max(1, Math.floor(this.container.clientWidth / 350)); const w = this.container.clientWidth; + const n = Math.max(1, Math.floor(w / this._targetWidth(w))); if (n === this.colCount && w === this._lastWidth) return; const isFirst = this.colCount === 0; diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 8ad2413..699ec4c 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -103,6 +103,10 @@ genPrefs: compactGallery(checkbox, false): "Compact media gallery (no profile info or text)" + gallerySize(select, "Medium"): + "Gallery column size" + options: @["Small", "Medium", "Large"] + mediaView(select, "Timeline"): "Default media view" options: @["Timeline", "Grid", "Gallery"] diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index 192073f..e167448 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -293,7 +293,15 @@ .gallery-masonry { margin: 10px 0; column-gap: 10px; - column-width: 350px; + column-width: unquote("clamp(190px, 22vw, 350px)"); + + &[data-col-size="small"] { + column-width: unquote("max(130px, 11vw)"); + } + + &[data-col-size="large"] { + column-width: unquote("clamp(350px, 22vw, 480px)"); + } &.masonry-active { column-width: unset; @@ -470,27 +478,8 @@ } } -@media (max-width: 900px) { - .timeline.media-grid-view { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - @media (max-width: 520px) { - .timeline.media-grid-view { - grid-template-columns: 1fr; - } - .timeline.media-gallery-view { padding: 8px 0; - - .gallery-masonry { - columns: 1; - column-gap: 0; - - &.masonry-active { - columns: unset; - } - } } } diff --git a/src/views/general.nim b/src/views/general.nim index 7bad908..d7af7e8 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=31") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=32") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") if theme.len > 0: diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 0045a5a..0bd9789 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -4,6 +4,7 @@ import karax/[karaxdsl, vdom, vstyles] import ".."/[types, utils] const smallWebp* = "?name=small&format=webp" +const mediumWebp* = "?name=medium&format=webp" proc getSmallPic*(url: string): string = result = url @@ -11,6 +12,12 @@ proc getSmallPic*(url: string): string = result &= smallWebp result = getPicUrl(result) +proc getMediumPic*(url: string): string = + result = url + if "?" notin url and not url.endsWith("placeholder.png"): + result &= mediumWebp + result = getPicUrl(result) + proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = var c = "icon-" & icon if class.len > 0: c = &"{c} {class}" diff --git a/src/views/timeline.nim b/src/views/timeline.nim index f4899c8..b911765 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -65,7 +65,7 @@ proc renderNoneFound(): VNode = h2(class="timeline-none"): text "No items found" -proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = +proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode = buildHtml(tdiv(class="thread-line")): let sortedThread = thread.sortedByIt(it.id) for i, tweet in sortedThread: @@ -79,7 +79,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = let show = i == thread.high and sortedThread[0].id != tweet.threadId let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" renderTweet(tweet, prefs, path, class=(header & "thread"), - index=i, last=(i == thread.high)) + index=i, last=(i == thread.high), bigThumb=bigThumb) proc renderUser(user: User; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-item", data-username=user.username)): @@ -146,10 +146,12 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; let filtered = filterThreads(results.content, prefs) if results.query.view == "gallery": - tdiv(class=if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"): + let bigThumb = prefs.gallerySize == "Large" + let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry" + tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii): for thread in filtered: - if thread.len == 1: renderTweet(thread[0], prefs, path) - else: renderThread(thread, prefs, path) + if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb) + else: renderThread(thread, prefs, path, bigThumb) else: for thread in filtered: if thread.len == 1: renderTweet(thread[0], prefs, path) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index aae56e9..ba8e9f1 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -42,13 +42,15 @@ proc renderAltText(altText: string): VNode = buildHtml(p(class="alt-text")): text "ALT " & altText -proc renderPhotoAttachment(photo: Photo): VNode = +proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode = buildHtml(tdiv(class="attachment")): let named = "name=" in photo.url - small = if named: photo.url else: photo.url & smallWebp + thumb = if named: photo.url + elif bigThumb: photo.url & mediumWebp + else: photo.url & smallWebp a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): - genImg(small, alt=photo.altText) + genImg(thumb, alt=photo.altText) if photo.altText.len > 0: renderAltText(photo.altText) @@ -76,11 +78,11 @@ proc renderVideoUnavailable(video: Video): VNode = else: p: text "This media is unavailable" -proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""): VNode = +proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode = let playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4 else: videoData.playbackType - thumb = getSmallPic(videoData.thumb) + thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb) buildHtml(tdiv(class="attachment")): if not videoData.available: @@ -106,12 +108,12 @@ proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""): VNode = tdiv(class="overlay-duration"): text getDuration(videoData) verbatim "

" -proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = +proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode = let hasCardContent = video.description.len > 0 or video.title.len > 0 buildHtml(tdiv(class="attachments card")): tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))): - renderVideoAttachment(video, prefs, path) + renderVideoAttachment(video, prefs, path, bigThumb) if hasCardContent: tdiv(class="card-content"): h2(class="card-title"): text video.title @@ -138,14 +140,14 @@ proc renderGif(gif: Gif; prefs: Prefs): VNode = buildHtml(tdiv(class="attachments media-gif")): renderGifAttachment(gif, prefs) -proc renderMedia(media: seq[Media]; prefs: Prefs; path: string): VNode = +proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode = if media.len == 0: return nil if media.len == 1: let item = media[0] if item.kind == videoMedia: - return renderVideo(item.video, prefs, path) + return renderVideo(item.video, prefs, path, bigThumb) if item.kind == gifMedia: return renderGif(item.gif, prefs) @@ -162,9 +164,9 @@ proc renderMedia(media: seq[Media]; prefs: Prefs; path: string): VNode = for mediaItem in mediaGroup: case mediaItem.kind of photoMedia: - renderPhotoAttachment(mediaItem.photo) + renderPhotoAttachment(mediaItem.photo, bigThumb) of videoMedia: - renderVideoAttachment(mediaItem.video, prefs, path) + renderVideoAttachment(mediaItem.video, prefs, path, bigThumb) of gifMedia: renderGifAttachment(mediaItem.gif, prefs) @@ -336,7 +338,7 @@ proc renderLocation*(tweet: Tweet): string = return $node proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; - last=false; mainTweet=false; afterTweet=false): VNode = + last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode = var divClass = class if index == -1 or last: divClass = "thread-last " & class @@ -389,7 +391,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; renderCard(tweet.card.get(), prefs, path) if tweet.media.len > 0: - renderMedia(tweet.media, prefs, path) + renderMedia(tweet.media, prefs, path, bigThumb) if tweet.poll.isSome: renderPoll(tweet.poll.get()) From 34296674143c4f94036805e2d7ca168f6e25e613 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 21 Mar 2026 18:59:05 +0100 Subject: [PATCH 210/309] Fix null legacy tweet crash Fixes #1383 --- src/parser.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parser.nim b/src/parser.nim index d22e6d1..c7a6e76 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -314,7 +314,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); replyId: int64 = 0): Tweet = - if js.isNull: return + if js.isNull: return Tweet() let time = if js{"created_at"}.notNull: js{"created_at"}.getTime From 741060c78b64aa4a769a8b8223ec6087cf484f2d Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 21 Mar 2026 20:28:41 +0100 Subject: [PATCH 211/309] Increase maxRetries in CI conf --- .github/workflows/run-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 46ec965..00fc4fa 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -128,6 +128,7 @@ jobs: run: | cp nitter.example.conf nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf + sed -i 's/maxRetries = 1/maxRetries = 3/g' nitter.conf # Run both Nimble tasks concurrently nim r tools/rendermd.nim & From e7e7050c6e133d453d91f743725ee709e34ba21b Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 30 Mar 2026 00:58:02 +0200 Subject: [PATCH 212/309] Add support for viewing account info Fixes #1381 --- src/api.nim | 7 +++ src/consts.nim | 1 + src/parser.nim | 42 +++++++++++++ src/parserutils.nim | 8 +++ src/redis_cache.nim | 13 ++++ src/routes/timeline.nim | 20 ++++++- src/sass/profile/_base.scss | 1 + src/sass/profile/about-account.scss | 71 ++++++++++++++++++++++ src/types.nim | 18 ++++++ src/views/about_account.nim | 93 +++++++++++++++++++++++++++++ src/views/general.nim | 2 +- src/views/profile.nim | 11 +++- 12 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 src/sass/profile/about-account.scss create mode 100644 src/views/about_account.nim diff --git a/src/api.nim b/src/api.nim index 9e496a4..2bd1e3b 100644 --- a/src/api.nim +++ b/src/api.nim @@ -66,6 +66,13 @@ proc getGraphUserById*(id: string): Future[User] {.async.} = js = await fetchRaw(url) result = parseGraphUser(js) +proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} = + if username.len == 0: return + let + url = apiReq(graphAboutAccount, """{"screenName":"$1"}""" % username) + js = await fetch(url) + result = parseAboutAccount(js) + proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let diff --git a/src/consts.nim b/src/consts.nim index c81443c..981f991 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -25,6 +25,7 @@ const graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" + graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery" gqlFeatures* = """{ "android_ad_formats_media_component_render_overlay_enabled": false, diff --git a/src/parser.nim b/src/parser.nim index c7a6e76..70cf4a7 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -68,6 +68,48 @@ proc parseGraphUser(js: JsonNode): User = with verifiedType, user{"verification", "verified_type"}: result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) +proc parseAboutAccount*(js: JsonNode): AccountInfo = + if js.isNull: return + + let user = ? js{"data", "user_result_by_screen_name", "result"} + + if user{"unavailable_reason"}.getStr == "Suspended": + result.suspended = true + return + + result = AccountInfo( + username: user{"core", "screen_name"}.getStr, + fullname: user{"core", "name"}.getStr, + joinDate: user{"core", "created_at"}.getTime, + userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""), + affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr, + ) + + if user{"is_blue_verified"}.getBool(false): + result.verifiedType = blue + with verifiedType, user{"verification", "verified_type"}: + result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + + with about, user{"about_profile"}: + result.basedIn = about{"account_based_in"}.getStr + result.source = about{"source"}.getStr + result.affiliateUsername = about{"affiliate_username"}.getStr + + try: + result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt + except ValueError: + discard + + with lastChange, about{"username_changes", "last_changed_at_msec"}: + result.lastUsernameChange = lastChange.getTimeFromMsStr + + with info, user{"verification_info"}: + result.isIdentityVerified = info{"is_identity_verified"}.getBool + with reason, info{"reason"}: + result.overrideVerifiedYear = reason{"override_verified_year"}.getInt + with since, reason{"verified_since_msec"}: + result.verifiedSince = since.getTimeFromMsStr + proc parseGraphList*(js: JsonNode): List = if js.isNull: return diff --git a/src/parserutils.nim b/src/parserutils.nim index 518724e..8d6ea2e 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -88,6 +88,14 @@ proc getTimeFromMs*(js: JsonNode): DateTime = let seconds = ms div 1000 return fromUnix(seconds).utc() +proc getTimeFromMsStr*(js: JsonNode): DateTime = + var ms: int64 + try: ms = parseBiggestInt(js.getStr("0")) + except ValueError: return + if ms == 0: return + let seconds = ms div 1000 + return fromUnix(seconds).utc() + proc getId*(id: string): int64 {.inline.} = let start = id.rfind("-") if start < 0: diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 559d299..0ed15ce 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -158,6 +158,19 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = # if not result.isNil: # await cache(result) +proc cache*(data: AccountInfo; name: string) {.async.} = + await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data))) + +proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} = + if username.len == 0: return + let name = toLower(username) + let cached = await get("ai:" & name) + if cached != redisNil: + cached.deserialize(AccountInfo) + elif fetch: + result = await getAboutAccount(username) + await cache(result, name) + proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return let rail = await get("pr2:" & toLower(id)) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 8a11e0e..0eb48e2 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -4,13 +4,13 @@ import jester, karax/vdom import router_utils import ".."/[types, redis_cache, formatters, query, api] -import ../views/[general, profile, timeline, status, search] +import ../views/[general, profile, timeline, status, search, about_account] export vdom export uri, sequtils export router_utils export redis_cache, formatters, query, api -export profile, timeline, status +export profile, timeline, status, about_account proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query = let view = request.params.getOrDefault("view") @@ -57,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] getCachedPhotoRail(userId) user = getCachedUser(name) + info = getCachedAccountInfo(name, fetch=false) result = case query.kind @@ -67,6 +68,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] result.user = await user result.photoRail = await rail + result.accountInfo = await info result.tweets.query = query @@ -119,6 +121,20 @@ proc createTimelineRouter*(cfg: Config) = resp Http400, showError("Missing screen_name parameter", cfg) redirect("/" & username) + get "/@name/about/?": + cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) + let + prefs = requestPrefs() + name = @"name" + info = await getCachedAccountInfo(name) + if info.suspended: + resp showError(getSuspended(name), cfg) + if info.username.len == 0: + resp Http404, showError("User \"" & name & "\" not found", cfg) + let aboutHtml = renderAboutAccount(info) + resp renderMain(aboutHtml, request, cfg, prefs, + "About @" & info.username) + get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index 4daddf6..2482460 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -2,6 +2,7 @@ @import "_mixins"; @import "card"; +@import "about-account"; @import "photo-rail"; .profile-tabs { diff --git a/src/sass/profile/about-account.scss b/src/sass/profile/about-account.scss new file mode 100644 index 0000000..aa12f49 --- /dev/null +++ b/src/sass/profile/about-account.scss @@ -0,0 +1,71 @@ +@import '_variables'; + +.about-account { + max-width: 500px; + width: 100%; + margin: 20px auto 0; + align-self: flex-start; + background: var(--bg_panel); + border-radius: 4px; + padding: 12px 20px 20px; +} + +.about-account-header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border_grey); +} + +.about-account-avatar img { + width: 72px; + height: 72px; + border-radius: 50%; + margin-bottom: 4px; +} + +.about-account-name { + @include breakable; + font-weight: bold; +} + +.about-account-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.about-account-at { + font-size: 18px; + font-weight: bold; +} + +.about-account-row { + display: flex; + align-items: center; + gap: 10px; + + > span:first-child { + color: var(--fg_faded); + flex-shrink: 0; + } + + > div { + display: flex; + flex-direction: column; + } +} + +.about-account-label { + color: var(--fg_faded); + font-size: 13px; +} + +@media(max-width: 700px) { + .about-account { + max-width: none; + margin: 10px; + } +} diff --git a/src/types.nim b/src/types.nim index 7f98b9d..9c34722 100644 --- a/src/types.nim +++ b/src/types.nim @@ -96,6 +96,23 @@ type suspended*: bool joinDate*: DateTime + AccountInfo* = object + username*: string + fullname*: string + userPic*: string + joinDate*: DateTime + verifiedType*: VerifiedType + suspended*: bool + basedIn*: string + source*: string + usernameChanges*: int + lastUsernameChange*: DateTime + affiliateUsername*: string + affiliateLabel*: string + isIdentityVerified*: bool + verifiedSince*: DateTime + overrideVerifiedYear*: int + VideoType* = enum m3u8 = "application/x-mpegURL" mp4 = "video/mp4" @@ -273,6 +290,7 @@ type photoRail*: PhotoRail pinned*: Option[Tweet] tweets*: Timeline + accountInfo*: AccountInfo List* = object id*: string diff --git a/src/views/about_account.nim b/src/views/about_account.nim new file mode 100644 index 0000000..aedd444 --- /dev/null +++ b/src/views/about_account.nim @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, strformat, times +import karax/[karaxdsl, vdom] + +import renderutils +import ".."/[types, formatters] + +proc renderAboutAccount*(info: AccountInfo): VNode = + let user = User( + username: info.username, + fullname: info.fullname, + userPic: info.userPic, + verifiedType: info.verifiedType + ) + + buildHtml(tdiv(class="about-account")): + tdiv(class="about-account-header"): + a(class="about-account-avatar", href=(&"/{info.username}")): + genImg(getUserPic(info.userPic, "_200x200")) + tdiv(class="about-account-name"): + linkUser(user, class="profile-card-fullname") + verifiedIcon(user) + linkUser(user, class="profile-card-username") + + tdiv(class="about-account-body"): + tdiv(class="about-account-row"): + span: icon "calendar" + tdiv: + span(class="about-account-label"): text "Date joined" + span(class="about-account-value"): + text info.joinDate.format("MMMM YYYY") + + if info.basedIn.len > 0: + tdiv(class="about-account-row"): + span: icon "location" + tdiv: + span(class="about-account-label"): text "Account based in" + span(class="about-account-value"): text info.basedIn + + if info.verifiedType != VerifiedType.none: + if info.overrideVerifiedYear != 0: + tdiv(class="about-account-row"): + span: icon "ok" + tdiv: + span(class="about-account-label"): text "Verified" + span(class="about-account-value"): + let year = abs(info.overrideVerifiedYear) + let era = if info.overrideVerifiedYear < 0: " BCE" else: "" + text "Since " & $year & era + elif info.verifiedSince.year > 0: + tdiv(class="about-account-row"): + span: icon "ok" + tdiv: + span(class="about-account-label"): text "Verified" + span(class="about-account-value"): + text "Since " & info.verifiedSince.format("MMMM YYYY") + + if info.isIdentityVerified: + tdiv(class="about-account-row"): + span: icon "ok" + tdiv: + span(class="about-account-label"): text "ID Verified" + span(class="about-account-value"): text "Yes" + + if info.affiliateUsername.len > 0: + tdiv(class="about-account-row"): + span: icon "group" + tdiv: + span(class="about-account-label"): text "An affiliate of" + span(class="about-account-value"): + a(href=(&"/{info.affiliateUsername}")): + if info.affiliateLabel.len > 0: + text info.affiliateLabel & " (@" & info.affiliateUsername & ")" + else: + text "@" & info.affiliateUsername + + if info.usernameChanges > 0: + tdiv(class="about-account-row"): + span(class="about-account-at"): text "@" + tdiv: + span(class="about-account-label"): + text $info.usernameChanges & " username change" + if info.usernameChanges > 1: text "s" + if info.lastUsernameChange.year > 0: + span(class="about-account-value"): + text "Last on " & info.lastUsernameChange.format("MMMM YYYY") + + if info.source.len > 0: + tdiv(class="about-account-row"): + span: icon "link" + tdiv: + span(class="about-account-label"): text "Connected via" + span(class="about-account-value"): text info.source diff --git a/src/views/general.nim b/src/views/general.nim index d7af7e8..f839eb8 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=32") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=34") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") if theme.len > 0: diff --git a/src/views/profile.nim b/src/views/profile.nim index 83d300a..5b751d6 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode = span(class="profile-stat-num"): text insertSep($num, ',') -proc renderUserCard*(user: User; prefs: Prefs): VNode = +proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode = buildHtml(tdiv(class="profile-card")): tdiv(class="profile-card-info"): let @@ -46,6 +46,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = else: span: text place + if info.basedIn.len > 0: + tdiv(class="profile-location"): + span: icon "location" + span: text "Based in " & info.basedIn + if user.website.len > 0: tdiv(class="profile-website"): span: @@ -54,7 +59,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = a(href=url): text url.shortLink tdiv(class="profile-joindate"): - span(title=getJoinDateFull(user)): + a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)): icon "calendar", getJoinDate(user) tdiv(class="profile-card-extra-links"): @@ -115,7 +120,7 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = if not isGalleryView: let sticky = if prefs.stickyProfile: " sticky" else: "" tdiv(class=("profile-tab" & sticky)): - renderUserCard(profile.user, prefs) + renderUserCard(profile.user, prefs, profile.accountInfo) if profile.photoRail.len > 0: renderPhotoRail(profile) From 0c7583432c0f7c92c69b5de80c748fd755017ce2 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 30 Mar 2026 01:20:33 +0200 Subject: [PATCH 213/309] Increase CI test maxRetries --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 00fc4fa..0549c6d 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -128,7 +128,7 @@ jobs: run: | cp nitter.example.conf nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf - sed -i 's/maxRetries = 1/maxRetries = 3/g' nitter.conf + sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf # Run both Nimble tasks concurrently nim r tools/rendermd.nim & From 7d431781c37748faef5ed15bfa6b4865d875b99f Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 30 Mar 2026 01:42:34 +0200 Subject: [PATCH 214/309] Increase CI reruns --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 0549c6d..c86087a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -142,4 +142,4 @@ jobs: run: | ./nitter & cd tests - poetry run pytest -n3 --reruns=3 --rs . + poetry run pytest -n3 --reruns=5 --rs . From 8114eefa1939ace54c13107e2641f0d2f09afcc4 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 31 Mar 2026 01:34:20 +0200 Subject: [PATCH 215/309] Add support for broadcasts Fixes #303 --- public/js/hlsPlayback.js | 4 ++- src/api.nim | 19 ++++++++++ src/apiutils.nim | 19 +++++----- src/consts.nim | 3 ++ src/formatters.nim | 34 +++++++++++++----- src/nitter.nim | 4 ++- src/parser.nim | 31 ++++++++++++++-- src/redis_cache.nim | 14 ++++++++ src/routes/broadcast.nim | 44 +++++++++++++++++++++++ src/routes/media.nim | 20 +++++------ src/sass/_broadcast.scss | 75 +++++++++++++++++++++++++++++++++++++++ src/sass/index.scss | 3 +- src/sass/tweet/_base.scss | 4 +++ src/types.nim | 14 ++++++++ src/utils.nim | 7 ++-- src/views/broadcast.nim | 75 +++++++++++++++++++++++++++++++++++++++ src/views/general.nim | 2 +- src/views/tweet.nim | 7 ++-- 18 files changed, 338 insertions(+), 41 deletions(-) create mode 100644 src/routes/broadcast.nim create mode 100644 src/sass/_broadcast.scss create mode 100644 src/views/broadcast.nim diff --git a/public/js/hlsPlayback.js b/public/js/hlsPlayback.js index 5cd46a6..9919fec 100644 --- a/public/js/hlsPlayback.js +++ b/public/js/hlsPlayback.js @@ -3,6 +3,7 @@ function playVideo(overlay) { const video = overlay.parentElement.querySelector('video'); const url = video.getAttribute("data-url"); + const startTime = parseFloat(video.getAttribute("data-start") || "0"); video.setAttribute("controls", ""); overlay.style.display = "none"; @@ -12,12 +13,13 @@ function playVideo(overlay) { hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.loadLevel = hls.levels.length - 1; - hls.startLoad(); + hls.startLoad(startTime); video.play(); }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; video.addEventListener('canplay', function() { + if (startTime > 0) video.currentTime = startTime; video.play(); }); } diff --git a/src/api.nim b/src/api.nim index 2bd1e3b..f3c6c12 100644 --- a/src/api.nim +++ b/src/api.nim @@ -73,6 +73,25 @@ proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} = js = await fetch(url) result = parseAboutAccount(js) +proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq = + let url = ApiUrl(endpoint: endpoint, params: params) + ApiReq(cookie: url, oauth: url) + +proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} = + if id.len == 0: return + let + req = apiReq(graphBroadcast, """{"id":"$1"}""" % id) + js = await fetch(req) + result = parseBroadcastInfo(js) + +proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} = + if mediaKey.len == 0: return + let + streamReq = restReq(restLiveStream & mediaKey) + streamJs = await fetch(streamReq) + result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr( + streamJs{"source", "location"}.getStr) + proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let diff --git a/src/apiutils.nim b/src/apiutils.nim index fe0479a..d6952e8 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -34,13 +34,14 @@ proc setApiProxy*(url: string) = apiProxy = "http://" & apiProxy proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = - case sessionKind - of oauth: - let o = req.oauth - parseUri("https://api.x.com/graphql") / o.endpoint ? o.params - of cookie: - let c = req.cookie - parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params + let url = case sessionKind + of oauth: req.oauth + of cookie: req.cookie + let base = case sessionKind + of oauth: "https://api.x.com" + of cookie: "https://x.com/i/api" + let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/" + parseUri(base) / (prefix & url.endpoint) ? url.params proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = let @@ -89,7 +90,7 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result["sec-fetch-dest"] = "empty" result["sec-fetch-mode"] = "cors" result["sec-fetch-site"] = "same-site" - if disableTid: + if disableTid or "/1.1/" in url.path: result["authorization"] = bearerToken2 else: result["authorization"] = bearerToken @@ -116,7 +117,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = pool.use(await genHeaders(session, url)): template getContent = # TODO: this is a temporary simple implementation - if apiProxy.len > 0: + if apiProxy.len > 0 and "/1.1/" notin url.path: resp = await c.get(($url).replace("https://", apiProxy)) else: resp = await c.get($url) diff --git a/src/consts.nim b/src/consts.nim index 981f991..29a582b 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -27,6 +27,9 @@ const graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery" + graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery" + restLiveStream* = "1.1/live_video_stream/status/" + gqlFeatures* = """{ "android_ad_formats_media_component_render_overlay_enabled": false, "android_graphql_skip_api_media_color_palette": false, diff --git a/src/formatters.nim b/src/formatters.nim index 5d06535..aef1c12 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -91,7 +91,17 @@ proc getM3u8Url*(content: string): string = if re.find(content, m3u8Regex, matches) != -1: result = matches[0] -proc proxifyVideo*(manifest: string; proxy: bool): string = +proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string = + let (baseUrl, basePath) = + if manifestUrl.len > 0: + let + u = parseUri(manifestUrl) + origin = u.scheme & "://" & u.hostname + idx = manifestUrl.rfind('/') + dirPath = if idx > 8: manifestUrl[0 .. idx] else: "" + (origin, dirPath) + else: + ("https://video.twimg.com", "") var replacements: seq[(string, string)] for line in manifest.splitLines: let url = @@ -99,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string = elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] else: line - if url.startsWith('/'): - let path = "https://video.twimg.com" & url - replacements.add (url, if proxy: path.getVidUrl else: path) + let resolved = + if url.startsWith('/'): baseUrl & url + elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and + not url.startsWith("http") and ('.' in url): basePath & url + else: "" + if resolved.len > 0: + replacements.add (url, if proxy: resolved.getVidUrl else: resolved) return manifest.multiReplace(replacements) proc getUserPic*(userPic: string; style=""): string = @@ -154,16 +168,18 @@ proc getShortTime*(tweet: Tweet): string = else: result = "now" -proc getDuration*(video: Video): string = - let - ms = video.durationMs +proc getDuration*(ms: int): string = + let sec = int(round(ms / 1000)) min = floorDiv(sec, 60) hour = floorDiv(min, 60) if hour > 0: - return &"{hour}:{min mod 60}:{sec mod 60:02}" + &"{hour}:{min mod 60:02}:{sec mod 60:02}" else: - return &"{min mod 60}:{sec mod 60:02}" + &"{min mod 60}:{sec mod 60:02}" + +proc getDuration*(video: Video): string = + getDuration(video.durationMs) proc getLink*(id: int64; username="i"; focus=true): string = var username = username diff --git a/src/nitter.nim b/src/nitter.nim index ba72818..d1c0ef1 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, debug, - unsupported, embed, resolver, router_utils] + unsupported, embed, resolver, broadcast, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -58,6 +58,7 @@ createSearchRouter(cfg) createMediaRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) +createBroadcastRouter(cfg) createDebugRouter(cfg) settings: @@ -121,5 +122,6 @@ routes: extend preferences, "" extend resolver, "" extend embed, "" + extend broadcastRoute, "" extend debug, "" extend unsupported, "" diff --git a/src/parser.nim b/src/parser.nim index 70cf4a7..0e45250 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -110,6 +110,22 @@ proc parseAboutAccount*(js: JsonNode): AccountInfo = with since, reason{"verified_since_msec"}: result.verifiedSince = since.getTimeFromMsStr +proc parseBroadcastInfo*(js: JsonNode): Broadcast = + let bc = ? js{"data", "broadcast"} + result = Broadcast( + id: bc{"broadcast_id"}.getStr, + title: bc{"status"}.getStr, + state: bc{"state"}.getStr.toUpperAscii, + thumb: bc{"image_url"}.getStr, + mediaKey: bc{"media_key"}.getStr, + totalWatched: bc{"total_watched"}.getInt, + startTime: bc{"start_time"}.getTimeFromMs, + endTime: bc{"end_time"}.getTimeFromMs, + replayStart: bc{"edited_replay", "start_time"}.getInt, + availableForReplay: bc{"available_for_replay"}.getBool, + user: parseGraphUser(bc) + ) + proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -287,14 +303,23 @@ proc parsePromoVideo(js: JsonNode): Video = result.variants.add variant proc parseBroadcast(js: JsonNode): Card = - let image = js{"broadcast_thumbnail_large"}.getImageVal + let + image = js{"broadcast_thumbnail_large"}.getImageVal + broadcastUrl = js{"broadcast_url"}.getStrVal + broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1] + streamUrl = "/i/broadcasts/" & broadcastId & "/stream" result = Card( kind: broadcast, - url: js{"broadcast_url"}.getStrVal, + url: "/i/broadcasts/" & broadcastId, title: js{"broadcaster_display_name"}.getStrVal, text: js{"broadcast_title"}.getStrVal, image: image, - video: some Video(thumb: image) + video: some Video( + thumb: image, + available: true, + playbackType: m3u8, + variants: @[VideoVariant(contentType: m3u8, url: streamUrl)] + ) ) proc parseCard(js: JsonNode; urls: JsonNode): Card = diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 0ed15ce..bfd271f 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -158,6 +158,20 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = # if not result.isNil: # await cache(result) +proc cache*(data: Broadcast) {.async.} = + if data.id.len == 0: return + await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data))) + +proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} = + if id.len == 0: return + let cached = await get("bc:" & id) + if cached != redisNil: + cached.deserialize(Broadcast) + else: + result = await getBroadcastInfo(id) + await cache(result) + result.m3u8Url = await fetchBroadcastStream(result.mediaKey) + proc cache*(data: AccountInfo; name: string) {.async.} = await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data))) diff --git a/src/routes/broadcast.nim b/src/routes/broadcast.nim new file mode 100644 index 0000000..d3bb95a --- /dev/null +++ b/src/routes/broadcast.nim @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import asyncdispatch, strutils +import jester + +import router_utils +import ".."/[types, formatters, redis_cache] +import ../views/[general, broadcast] +import media + +export broadcast + +proc createBroadcastRouter*(cfg: Config) = + router broadcastRoute: + get "/i/broadcasts/@id": + cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) + var bc: Broadcast + try: + bc = await getCachedBroadcast(@"id") + except: + discard + + if bc.id.len == 0: + resp Http404, showError("Broadcast not found", cfg) + + let prefs = requestPrefs() + resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs, + bc.title, ogTitle=bc.title) + + get "/i/broadcasts/@id/stream": + cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) + var bc: Broadcast + try: + bc = await getCachedBroadcast(@"id") + except: + discard + + if bc.m3u8Url.len == 0: + resp Http404 + + let manifest = await safeFetch(bc.m3u8Url) + if manifest.len == 0: + resp Http502 + + resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime diff --git a/src/routes/media.nim b/src/routes/media.nim index 92eee5f..df30d5f 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -86,6 +86,12 @@ proc decoded*(req: jester.Request; index: int): string = if based: decode(encoded) else: decodeUrl(encoded) +proc normalizeImgUrl*(url: var string) = + if not url.startsWith("http"): + if "twimg.com" notin url: + url.insert(twimg) + url.insert(https) + proc createMediaRouter*(cfg: Config) = router media: get "/pic/?": @@ -94,11 +100,7 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/orig\/(enc)?\/?(.+)": var url = decoded(request, 1) cond "/amplify_video/" notin url - - if "twimg.com" notin url: - url.insert(twimg) - if not url.startsWith(https): - url.insert(https) + normalizeImgUrl(url) url.add("?name=orig") let uri = parseUri(url) @@ -110,11 +112,7 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/(enc)?\/?(.+)": var url = decoded(request, 1) cond "/amplify_video/" notin url - - if "twimg.com" notin url: - url.insert(twimg) - if not url.startsWith(https): - url.insert(https) + normalizeImgUrl(url) let uri = parseUri(url) cond isTwitterUrl(uri) == true @@ -143,6 +141,6 @@ proc createMediaRouter*(cfg: Config) = if ".m3u8" in url: let vid = await safeFetch(url) - content = proxifyVideo(vid, requestPrefs().proxyVideos) + content = proxifyVideo(vid, requestPrefs().proxyVideos, url) resp content, m3u8Mime diff --git a/src/sass/_broadcast.scss b/src/sass/_broadcast.scss new file mode 100644 index 0000000..dd93606 --- /dev/null +++ b/src/sass/_broadcast.scss @@ -0,0 +1,75 @@ +.broadcast-page { + max-width: 800px; + width: 100%; + margin: 20px auto 0; +} + +.broadcast-panel { + background-color: var(--bg_panel); + border: 1px solid var(--border_grey); + border-radius: 8px; + overflow: hidden; +} + +.broadcast-player { + position: relative; + background: black; + + video, + img { + display: block; + width: 100%; + } +} + +.broadcast-info { + padding: 14px 16px; +} + +.broadcast-title { + font-size: 18px; + font-weight: bold; + margin: 0 0 12px; +} + +.broadcast-user-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.broadcast-user { + display: flex; + align-items: center; + gap: 10px; + color: var(--fg_color); + + img { + width: 40px; + height: 40px; + border-radius: 50%; + } +} + +.broadcast-username { + color: var(--fg_dark); +} + +.broadcast-meta { + color: var(--fg_faded); + font-size: 14px; + display: flex; + flex-direction: column; + align-items: flex-end; + flex-shrink: 0; + line-height: 1.5em; +} + +.broadcast-live { + background: #e0245e; + color: white; + padding: 1px 6px; + border-radius: 3px; + font-weight: bold; + font-size: 12px; +} diff --git a/src/sass/index.scss b/src/sass/index.scss index ea7227d..e60b9c4 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -7,6 +7,7 @@ @import "inputs"; @import "timeline"; @import "search"; +@import "broadcast"; body { // colors @@ -160,7 +161,7 @@ body.fixed-nav .container { display: inline-block; width: 14px; height: 14px; - margin-left: 2px; + margin-bottom: 2px; .verified-icon-circle { position: absolute; diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 5bf5a2c..7606c31 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -44,6 +44,10 @@ padding: 0; display: flex; justify-content: space-between; + + .verified-icon { + margin-left: 2px; + } } .fullname-and-username { diff --git a/src/types.nim b/src/types.nim index 9c34722..848f8ae 100644 --- a/src/types.nim +++ b/src/types.nim @@ -113,6 +113,20 @@ type verifiedSince*: DateTime overrideVerifiedYear*: int + Broadcast* = object + id*: string + title*: string + state*: string + thumb*: string + mediaKey*: string + m3u8Url*: string + totalWatched*: int + startTime*: DateTime + endTime*: DateTime + replayStart*: int + availableForReplay*: bool + user*: User + VideoType* = enum m3u8 = "application/x-mpegURL" mp4 = "video/mp4" diff --git a/src/utils.nim b/src/utils.nim index 6bd977e..391e2a3 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -17,7 +17,9 @@ const "abs.twimg.com", "pbs.twimg.com", "video.twimg.com", - "x.com" + "x.com", + "pscp.tv", + "video.pscp.tv" ] proc setHmacKey*(key: string) = @@ -55,7 +57,8 @@ proc filterParams*(params: Table): seq[(string, string)] = result.add p proc isTwitterUrl*(uri: Uri): bool = - uri.hostname in twitterDomains + uri.hostname in twitterDomains or + uri.hostname.endsWith(".video.pscp.tv") proc isTwitterUrl*(url: string): bool = isTwitterUrl(parseUri(url)) diff --git a/src/views/broadcast.nim b/src/views/broadcast.nim new file mode 100644 index 0000000..bfcb9ba --- /dev/null +++ b/src/views/broadcast.nim @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, strformat, times +import karax/[karaxdsl, vdom] + +import renderutils +import ".."/[types, utils, formatters] + +proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode = + let + isLive = bc.state == "RUNNING" + thumb = getPicUrl(bc.thumb) + source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"): + getVidUrl(bc.m3u8Url) else: bc.m3u8Url + stateText = + if isLive: "LIVE" + elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY") + elif bc.state.len > 0: bc.state + else: "Ended" + durationMs = + if bc.startTime.year > 1 and bc.endTime.year > 1: + int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000 + else: 0 + duration = if durationMs > 0: getDuration(durationMs) else: "" + + buildHtml(tdiv(class="broadcast-page")): + tdiv(class="broadcast-panel"): + tdiv(class="broadcast-player"): + if bc.m3u8Url.len > 0 and prefs.hlsPlayback: + video(poster=thumb, data-url=source, data-autoload="false", + data-start=($bc.replayStart), muted=prefs.muteVideos) + verbatim "
" + tdiv(class="overlay-circle"): span(class="overlay-triangle") + if isLive: + tdiv(class="broadcast-live"): text "LIVE" + elif duration.len > 0: + tdiv(class="overlay-duration"): text duration + verbatim "
" + elif bc.m3u8Url.len > 0: + img(src=thumb, alt=bc.title) + tdiv(class="video-overlay"): + buttonReferer "/enablehls", "Enable hls playback", path + if isLive: + tdiv(class="broadcast-live"): text "LIVE" + elif duration.len > 0: + tdiv(class="overlay-duration"): text duration + elif bc.thumb.len > 0: + img(src=thumb, alt=bc.title) + tdiv(class="video-overlay"): + if bc.availableForReplay: + p: text "Stream unavailable" + else: + p: text "Replay is not available" + else: + tdiv(class="video-overlay"): + p: text "Broadcast not found" + + tdiv(class="broadcast-info"): + h2(class="broadcast-title"): text bc.title + + tdiv(class="broadcast-user-row"): + a(class="broadcast-user", href=("/" & bc.user.username)): + genImg(getUserPic(bc.user.userPic, "_bigger")) + tdiv: + tdiv: + strong: text bc.user.fullname + verifiedIcon(bc.user) + span(class="broadcast-username"): text "@" & bc.user.username + + tdiv(class="broadcast-meta"): + if bc.totalWatched > 0: + span: text insertSep($bc.totalWatched, ',') & " views" + if isLive: + span(class="broadcast-live"): text stateText + else: + span: text stateText diff --git a/src/views/general.nim b/src/views/general.nim index f839eb8..4110bdc 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=34") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=35") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") if theme.len > 0: diff --git a/src/views/tweet.nim b/src/views/tweet.nim index ba8e9f1..e15cf1d 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -95,8 +95,8 @@ proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=fal let vars = videoData.variants.filterIt(it.contentType == playbackType) vidUrl = vars.sortedByIt(it.resolution)[^1].url - source = if prefs.proxyVideos: getVidUrl(vidUrl) - else: vidUrl + source = if prefs.proxyVideos and vidUrl.startsWith("http"): + getVidUrl(vidUrl) else: vidUrl case playbackType of mp4: video(poster=thumb, controls="", muted=prefs.muteVideos): @@ -105,7 +105,8 @@ proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=fal video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) verbatim "
" tdiv(class="overlay-circle"): span(class="overlay-triangle") - tdiv(class="overlay-duration"): text getDuration(videoData) + if videoData.durationMs > 0: + tdiv(class="overlay-duration"): text getDuration(videoData) verbatim "
" proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode = From 4e38317582d04aa33a4a94f9350982433da0b024 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 16 Apr 2026 02:31:52 +0200 Subject: [PATCH 216/309] Fix verified type enum parsing error Fixes #1387 --- src/parser.nim | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 0e45250..d55c546 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -6,6 +6,10 @@ import experimental/parser/unifiedcard proc parseGraphTweet(js: JsonNode): Tweet +proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType = + try: parseEnum[VerifiedType](s) + except ValueError: current + proc parseCommunityNote(js: JsonNode): string = let subtitle = js{"subtitle"} result = subtitle{"text"}.getStr @@ -35,7 +39,7 @@ proc parseUser(js: JsonNode; id=""): User = result.verifiedType = blue with verifiedType, js{"verified_type"}: - result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) result.expandUserEntities(js) @@ -66,7 +70,7 @@ proc parseGraphUser(js: JsonNode): User = result.verifiedType = blue with verifiedType, user{"verification", "verified_type"}: - result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) proc parseAboutAccount*(js: JsonNode): AccountInfo = if js.isNull: return @@ -88,7 +92,7 @@ proc parseAboutAccount*(js: JsonNode): AccountInfo = if user{"is_blue_verified"}.getBool(false): result.verifiedType = blue with verifiedType, user{"verification", "verified_type"}: - result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) + result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) with about, user{"about_profile"}: result.basedIn = about{"account_based_in"}.getStr From 74f5ff8accc0faace7c0955a4be03b3ad159609b Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 16 Apr 2026 02:46:43 +0200 Subject: [PATCH 217/309] Fix thread test --- tests/test_thread.py | 53 ++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/tests/test_thread.py b/tests/test_thread.py index 48231a8..aa8ce32 100644 --- a/tests/test_thread.py +++ b/tests/test_thread.py @@ -1,27 +1,38 @@ -from base import BaseTestCase, Conversation from parameterized import parameterized +from base import BaseTestCase, Conversation + thread = [ - ['octonion/status/975253897697611777', [], 'Based', ['Crystal', 'Julia'], [ - ['For', 'Then', 'Okay,', 'Python', 'Speed', 'Java', 'Coding', 'I', 'You'], - ['yeah,'] - ]], - - ['octonion/status/975254452625002496', ['Based'], 'Crystal', ['Julia'], []], - - ['octonion/status/975256058384887808', ['Based', 'Crystal'], 'Julia', [], []], - - ['gauravssnl/status/975364889039417344', - ['Based', 'For', 'Then', 'Okay,', 'Python'], 'Speed', [], [ - ['Java', 'Coding', 'I', 'You'], ['JAVA!'] - ]], - - ['d0m96/status/1141811379407425537', [], 'I\'m', - ['The', 'The', 'Today', 'Some', 'If', 'There', 'Above'], - [['Thank', 'Also,']]], - - ['gmpreussner/status/999766552546299904', [], 'A', [], - [['I', 'Especially'], ['I']]] + [ + "octonion/status/975253897697611777", + [], + "Based", + ["Crystal", "Julia"], + [["yeah,"]], + ], + ["octonion/status/975254452625002496", ["Based"], "Crystal", ["Julia"], []], + ["octonion/status/975256058384887808", ["Based", "Crystal"], "Julia", [], []], + [ + "gauravssnl/status/975364889039417344", + ["Based", "For", "Then", "Okay,", "Python"], + "Speed", + [], + [["Java", "Coding", "I", "You"], ["JAVA!"]], + ], + [ + "d0m96/status/1141811379407425537", + [], + "I'm", + ["The", "The", "Today", "Some", "If", "There", "Above"], + [["Thank", "Also,"]], + ], + [ + "gmpreussner/status/999766552546299904", + [], + "A", + [], + [["I", "Especially"], ["I"]], + ], ] From 82099de55bd0da029c6a489e00de8b34e06ab573 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 2 Jun 2026 17:21:20 +0200 Subject: [PATCH 218/309] Include session.kind in all debug output Fixes #1330 --- src/apiutils.nim | 34 +++++++++++++++++++++------------- src/auth.nim | 19 +++++++++++++++++-- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index d6952e8..6f4f727 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -130,7 +130,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = raise newException(BadClientError, "Bad client") if resp.status == $Http404 and result.len == 0: - echo "[sessions] transient 404 (empty body), retrying: ", url.path + echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", session: ", session.pretty raise rateLimitError() if resp.headers.hasKey(rlRemaining): @@ -147,7 +147,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) if errors notin errorsToSkip: - echo "Fetch error, API: ", url.path, ", errors: ", errors + echo "Fetch error, API: ", url.path, ", errors: ", errors, ", session: ", session.pretty if errors in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() @@ -162,7 +162,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = fetchBody if resp.status == $Http400: - echo "ERROR 400, ", url.path, ": ", result + echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty raise newException(InternalError, $url) except InternalError as e: raise e @@ -177,22 +177,30 @@ template fetchImpl(result, fetchBody) {.dirty.} = finally: release(session) -template retry(bod) = +template retry(bod) {.dirty.} = + var session: Session for i in 0 ..< maxRetries: try: + session = nil bod break except RateLimitError: - echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, - " request (", i, "/", maxRetries, ")..." + let api = if session.isNil: req.cookie.endpoint + else: req.endpoint(session) + if session.isNil: + echo "[sessions] Rate limited, retrying ", api, + " request (", i, "/", maxRetries, ")..." + else: + echo "[sessions] Rate limited, retrying ", api, + " request (", i, "/", maxRetries, ")..., session: ", session.pretty + session = nil if retryDelayMs > 0: await sleepAsync(retryDelayMs) proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = retry: - var - body: string - session = await getAndValidateSession(req) + var body: string + session = await getAndValidateSession(req) let url = req.toUrl(session.kind) @@ -200,22 +208,22 @@ proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = if body.startsWith('{') or body.startsWith('['): result = parseJson(body) else: - echo resp.status, ": ", body, " --- url: ", url + echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty result = newJNull() let error = result.getError if error != null and error notin errorsToSkip: - echo "Fetch error, API: ", url.path, ", error: ", error + echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty if error in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() proc fetchRaw*(req: ApiReq): Future[string] {.async.} = retry: - var session = await getAndValidateSession(req) + session = await getAndValidateSession(req) let url = req.toUrl(session.kind) fetchImpl result: if not (result.startsWith('{') or result.startsWith('[')): - echo resp.status, ": ", result, " --- url: ", url + echo resp.status, ": ", result, " --- url: ", url, ", session: ", session.pretty result.setLen(0) diff --git a/src/auth.nim b/src/auth.nim index d801489..259c360 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -18,7 +18,7 @@ proc setMaxConcurrentReqs*(reqs: int) = template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") -proc endpoint(req: ApiReq; session: Session): string = +proc endpoint*(req: ApiReq; session: Session): string = case session.kind of oauth: req.oauth.endpoint of cookie: req.cookie.endpoint @@ -50,6 +50,8 @@ proc getSessionPoolHealth*(): JsonNode = oldest = now.int64 newest = 0'i64 average = 0'i64 + oauthTotal, cookieTotal = 0 + oauthLimited, cookieLimited = 0 for session in sessionPool: let created = snowflakeToEpoch(session.id) @@ -59,8 +61,15 @@ proc getSessionPoolHealth*(): JsonNode = oldest = created average += created + case session.kind + of oauth: inc oauthTotal + of cookie: inc cookieTotal + if session.limited: limited.incl session.id + case session.kind + of oauth: inc oauthLimited + of cookie: inc cookieLimited for api in session.apis.keys: let @@ -84,6 +93,8 @@ proc getSessionPoolHealth*(): JsonNode = "sessions": %*{ "total": sessionPool.len, "limited": limited.card, + "oauth": %*{"total": oauthTotal, "limited": oauthLimited}, + "cookie": %*{"total": cookieTotal, "limited": cookieLimited}, "oldest": $fromUnix(oldest), "newest": $fromUnix(newest), "average": $fromUnix(average) @@ -100,6 +111,7 @@ proc getSessionPoolDebug*(): JsonNode = for session in sessionPool: let sessionJson = %*{ + "kind": $session.kind, "apis": newJObject(), "pending": session.pending, } @@ -173,7 +185,10 @@ proc getSession*(req: ApiReq): Future[Session] {.async.} = if not result.isNil and result.isReady(req): inc result.pending else: - log "no sessions available for API: ", req.cookie.endpoint + if result.isNil: + log "no sessions available for API: ", req.cookie.endpoint + else: + log "no sessions available for API: ", req.endpoint(result), ", last tried: ", result.pretty raise noSessionsError() proc setLimited*(session: Session; req: ApiReq) = From 5a4faa03678c6d049d66fdd8c99d225fef64f18e Mon Sep 17 00:00:00 2001 From: Ian Brown Date: Tue, 2 Jun 2026 13:31:40 -0700 Subject: [PATCH 219/309] Fix OpenSearch response crash (#1400) --- src/routes/search.nim | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/routes/search.nim b/src/routes/search.nim index 7d72f34..ba023fd 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -46,6 +46,7 @@ proc createSearchRouter*(cfg: Config) = redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash")) get "/opensearch": - let url = getUrlPrefix(cfg) & "/search?f=tweets&q=" - resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, - generateOpenSearchXML(cfg.title, cfg.hostname, url) + let + url = getUrlPrefix(cfg) & "/search?f=tweets&q=" + headers = {"Content-Type": "application/opensearchdescription+xml"} + resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url) From d5ff410c5d30c27fb6450230de80d82e80fed816 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 2 Jun 2026 23:57:06 +0200 Subject: [PATCH 220/309] Add same-origin referrer policy Fixes #1346 --- src/views/general.nim | 1 + 1 file changed, 1 insertion(+) diff --git a/src/views/general.nim b/src/views/general.nim index 4110bdc..f753610 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -84,6 +84,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; text cfg.title meta(name="viewport", content="width=device-width, initial-scale=1.0") + meta(name="referrer", content="same-origin") meta(name="theme-color", content="#1F1F1F") meta(property="og:type", content=ogType) meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText)) From 1d57f1f4323ecd4fc9c4a99040b56a4f7725b9d4 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 5 Jun 2026 22:26:38 +0200 Subject: [PATCH 221/309] Add video attribution link support --- src/types.nim | 1 + src/views/tweet.nim | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/types.nim b/src/types.nim index 848f8ae..c755589 100644 --- a/src/types.nim +++ b/src/types.nim @@ -264,6 +264,7 @@ type stats*: TweetStats retweet*: Option[Tweet] attribution*: Option[User] + attributionLink*: string mediaTags*: seq[User] quote*: Option[Tweet] card*: Option[Card] diff --git a/src/views/tweet.nim b/src/views/tweet.nim index e15cf1d..5546eb4 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -239,8 +239,9 @@ proc renderReply(tweet: Tweet): VNode = if i > 0: text " " a(href=("/" & u)): text "@" & u -proc renderAttribution(user: User; prefs: Prefs): VNode = - buildHtml(a(class="attribution", href=("/" & user.username))): +proc renderAttribution(user: User; prefs: Prefs; link = ""): VNode = + let href = if link.len > 0: link else: "/" & user.username + buildHtml(a(class="attribution", href=href)): renderMiniAvatar(user, prefs) strong: text user.fullname verifiedIcon(user) @@ -386,7 +387,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet) if tweet.attribution.isSome: - renderAttribution(tweet.attribution.get(), prefs) + renderAttribution(tweet.attribution.get(), prefs, tweet.attributionLink) if tweet.card.isSome and tweet.card.get().kind != hidden: renderCard(tweet.card.get(), prefs, path) From 083d65a8cf1d6bfc6638e8e63a7cfba3f18e8bd0 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 5 Jun 2026 22:26:41 +0200 Subject: [PATCH 222/309] Update tests --- tests/test_card.py | 29 ++++++++++++----------------- tests/test_thread.py | 2 +- tests/test_tweet.py | 6 +++--- tests/test_tweet_media.py | 14 +++++++------- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/tests/test_card.py b/tests/test_card.py index 129e65a..f0a916c 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -11,18 +11,18 @@ card = [ ['voidtarget/status/1094632512926605312', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', - 'gist.github.com', True] + 'gist.github.com', True], + + ['NASA/status/2061872347477418301', + 'Nancy Grace Roman Space Telescope Mission - NASA Science', + 'The Nancy Grace Roman Space Telescope will settle essential questions in the areas of dark energy, exoplanets, and astrophysics.', + 'science.nasa.gov', True] ] no_thumb = [ - ['FluentAI/status/1116417904831029248', - 'LinkedIn', - 'This link will take you to a page that’s not on LinkedIn', - 'lnkd.in'], - ['Thom_Wolf/status/1122466524860702729', - 'GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in', - '', + 'GitHub - facebookresearch/XLM: PyTorch original implementation of Cross-lingual Language Model', + 'PyTorch original implementation of Cross-lingual Language Model Pretraining.', 'github.com'], ['brent_p/status/1088857328680488961', @@ -37,14 +37,9 @@ no_thumb = [ ] playable = [ - ['nim_lang/status/1118234460904919042', - 'Nim development blog 2019-03', - 'Arne (aka Krux02)* debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g* bugs: * forwarding of .pure. * sizeof union* fe...', - 'youtube.com'], - - ['nim_lang/status/1121090879823986688', - 'Nim - First natively compiled language w/ hot code-reloading at...', - '#nim #c++ #ACCUConfNim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming cap...', + ['NASA/status/2047048645845897398', + 'NASA\'s Artemis II News Conference with Moon Astronauts', + 'Live from NASA\'s Johnson Space Center in Houston', 'youtube.com'] ] @@ -72,7 +67,7 @@ class CardTest(BaseTestCase): if len(description) > 0: self.assert_text(description, c.description) - @parameterized.expand(playable) + @parameterized.expand(playable, skip_on_empty=True) def test_card_playable(self, tweet, title, description, destination): self.open_nitter(tweet) c = Card(Conversation.main + " ") diff --git a/tests/test_thread.py b/tests/test_thread.py index aa8ce32..48319bb 100644 --- a/tests/test_thread.py +++ b/tests/test_thread.py @@ -8,7 +8,7 @@ thread = [ [], "Based", ["Crystal", "Julia"], - [["yeah,"]], + [["For", "Then"], ["yeah,"]], ], ["octonion/status/975254452625002496", ["Based"], "Crystal", ["Julia"], []], ["octonion/status/975256058384887808", ["Based", "Crystal"], "Julia", [], []], diff --git a/tests/test_tweet.py b/tests/test_tweet.py index bf9e267..0fa7672 100644 --- a/tests/test_tweet.py +++ b/tests/test_tweet.py @@ -71,8 +71,8 @@ emoji = [ ] retweet = [ - [7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'], - [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr'] + [7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', + 'Testing. 1234.'] ] @@ -120,7 +120,7 @@ class TweetTest(BaseTestCase): link = self.find_link_text(f'@{un}') self.assertIn(f'/{un}', link.get_property('href')) - @parameterized.expand(retweet) + @parameterized.expand(retweet, skip_on_empty=True) def test_retweet(self, index, url, retweet_by, fullname, username, text): self.open_nitter(url) tweet = get_timeline_tweet(index) diff --git a/tests/test_tweet_media.py b/tests/test_tweet_media.py index f54cea7..96f630a 100644 --- a/tests/test_tweet_media.py +++ b/tests/test_tweet_media.py @@ -28,14 +28,14 @@ video_m3u8 = [ ] gallery = [ - # ['mobile_test/status/451108446603980803', [ - # ['BkKovdrCUAAEz79', 'BkKovdcCEAAfoBO'] - # ]], + ['mobile_test/status/451108446603980803', [ + ['BkKovdrCUAAEz79', 'BkKovdcCEAAfoBO'] + ]], - # ['mobile_test/status/471539824713691137', [ - # ['Bos--KNIQAAA7Li', 'Bos--FAIAAAWpah'], - # ['Bos--IqIQAAav23'] - # ]], + ['mobile_test/status/471539824713691137', [ + ['Bos--KNIQAAA7Li', 'Bos--FAIAAAWpah'], + ['Bos--IqIQAAav23'] + ]], ['mobile_test/status/469530783384743936', [ ['BoQbwJAIUAA0QCY', 'BoQbwN1IMAAuTiP'], From e4e6dd13e6667bb5337194df14a031218d224219 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 5 Jun 2026 22:26:46 +0200 Subject: [PATCH 223/309] Update API endpoints --- src/api.nim | 49 ++++++++-------- src/apiutils.nim | 7 ++- src/consts.nim | 145 +++++++++++++++++------------------------------ src/parser.nim | 48 +++++++++++++--- 4 files changed, 122 insertions(+), 127 deletions(-) diff --git a/src/api.nim b/src/api.nim index f3c6c12..c66ca3a 100644 --- a/src/api.nim +++ b/src/api.nim @@ -25,27 +25,27 @@ proc mediaUrl(id, cursor: string; count=20): ApiReq = ) proc userTweetsUrl(id: string; cursor: string): ApiReq = - result = ApiReq( - # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), - oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"]) - ) - # might change this in the future pending testing - result.cookie = result.oauth + return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"]) + # result = ApiReq( + # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), + # oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"]) + # ) proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = - let cookieVars = userTweetsAndRepliesVars % [id, cursor] - result = ApiReq( - cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles), - oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"]) - ) + return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"]) + #let cookieVars = userTweetsAndRepliesVars % [id, cursor] + # result = ApiReq( + # cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles), + # oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"]) + # ) proc tweetDetailUrl(id: string; cursor: string): ApiReq = - let cookieVars = tweetDetailVars % [id, cursor] - result = ApiReq( - # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), - cookie: apiUrl(graphTweet, tweetVars % [id, cursor]), - oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) - ) + return apiReq(graphTweet, tweetVars % [id, cursor]) + # let cookieVars = tweetDetailVars % [id, cursor] + # result = ApiReq( + # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), + # oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) + # ) proc userUrl(username: string): ApiReq = let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username @@ -184,13 +184,13 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = var variables = %*{ "rawQuery": q, - "query_source": "typedQuery", "count": 20, + "querySource": "typed_query", "product": "Latest", - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false + "withGrokTranslatedBio":true, + "withQuickPromoteEligibilityTweetFields":false } + if after.len > 0 and maxId.len == 0: variables["cursor"] = % after let @@ -212,12 +212,11 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} var variables = %*{ "rawQuery": query.text, - "query_source": "typedQuery", "count": 20, + "querySource": "typed_query", "product": "People", - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false + "withGrokTranslatedBio":true, + "withQuickPromoteEligibilityTweetFields":false } if after.len > 0: variables["cursor"] = % after diff --git a/src/apiutils.nim b/src/apiutils.nim index 6f4f727..bf6c4b9 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -84,12 +84,13 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) + result["referer"] = "https://x.com/" result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24"""" result["sec-ch-ua-mobile"] = "?0" result["sec-ch-ua-platform"] = "Windows" result["sec-fetch-dest"] = "empty" result["sec-fetch-mode"] = "cors" - result["sec-fetch-site"] = "same-site" + result["sec-fetch-site"] = "same-origin" if disableTid or "/1.1/" in url.path: result["authorization"] = bearerToken2 else: @@ -114,7 +115,9 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - pool.use(await genHeaders(session, url)): + let headers = await genHeaders(session, url) + + pool.use(headers): template getContent = # TODO: this is a temporary simple implementation if apiProxy.len > 0 and "/1.1/" notin url.path: diff --git a/src/consts.nim b/src/consts.nim index 29a582b..f903528 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -7,109 +7,70 @@ const bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" - graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" - graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" - graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" - graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" - graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" - graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets" - graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" - graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia" - graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" - graphTweet* = "b4pV7sWOe97RncwHcGESUA/ConversationTimeline" - graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" - graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" - graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory" - graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" - graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" - graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" - graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" - graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" - graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery" + graphUser* = "IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName" + graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery" + graphUserById* = "-DAaa9jPxPswYeI2fZ9rug/UserResultByIdQuery" + graphUserTweetsV2* = "PHTSTXqZYuHIeK4B1HQprQ/UserWithProfileTweetsQueryV2" + graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweets* = "PNd0vlufvrcIwrAnBYKE9g/UserTweets" + graphUserTweetsAndReplies* = "EqtpEwt0CoQXmDfq5DKH0A/UserTweetsAndReplies" + graphUserMedia* = "g_rGPF0fLON-M9cyVjXuzA/UserMedia" + graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2" + graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline" + graphTweetDetail* = "6uCvnic3m5reVuehkvHa3w/TweetDetail" + graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery" + graphTweetEditHistory* = "MGElmrYILE8wUfI8GorUYA/TweetEditHistory" + graphSearchTimeline* = "-TFXKoMnMTKdEXcCn-eahw/SearchTimeline" - graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery" + graphListById* = "t9AbdyHaJVfjL9jsODwgpQ/ListByRestId" + graphListBySlug* = "LDQpQ89B5ipR8izCKrWU0g/ListBySlug" + graphListMembers* = "EM7YRaM3gCnzDESmchA7RA/ListMembers" + graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline" + graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery" + + graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery" restLiveStream* = "1.1/live_video_stream/status/" gqlFeatures* = """{ - "android_ad_formats_media_component_render_overlay_enabled": false, - "android_graphql_skip_api_media_color_palette": false, - "android_professional_link_spotlight_display_enabled": false, - "articles_api_enabled": false, - "articles_preview_enabled": true, - "blue_business_profile_image_shape_enabled": false, - "c9s_tweet_anatomy_moderator_badge_enabled": true, - "commerce_android_shop_module_enabled": false, - "communities_web_enable_tweet_community_results_fetch": true, - "creator_subscriptions_quote_tweet_preview_enabled": false, - "creator_subscriptions_subscription_count_enabled": false, - "creator_subscriptions_tweet_preview_api_enabled": true, - "freedom_of_speech_not_reach_fetch_enabled": true, - "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, - "grok_android_analyze_trend_fetch_enabled": false, - "grok_translations_community_note_auto_translation_is_enabled": false, - "grok_translations_community_note_translation_is_enabled": false, - "grok_translations_post_auto_translation_is_enabled": false, - "grok_translations_timeline_user_bio_auto_translation_is_enabled": false, - "hidden_profile_likes_enabled": false, - "highlights_tweets_tab_ui_enabled": false, - "immersive_video_status_linkable_timestamps": false, - "interactive_text_enabled": false, - "longform_notetweets_consumption_enabled": true, - "longform_notetweets_inline_media_enabled": true, - "longform_notetweets_richtext_consumption_enabled": true, - "longform_notetweets_rich_text_read_enabled": true, - "mobile_app_spotlight_module_enabled": false, - "payments_enabled": false, - "post_ctas_fetch_enabled": true, - "premium_content_api_read_enabled": false, + "rweb_video_screen_enabled": false, + "rweb_cashtags_enabled": true, "profile_label_improvements_pcf_label_in_post_enabled": true, - "profile_label_improvements_pcf_label_in_profile_enabled": false, - "responsive_web_edit_tweet_api_enabled": true, - "responsive_web_enhance_cards_enabled": false, - "responsive_web_graphql_exclude_directive_enabled": true, - "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, + "responsive_web_profile_redirect_enabled": false, + "rweb_tipjar_consumption_enabled": false, + "verified_phone_label_enabled": false, + "creator_subscriptions_tweet_preview_api_enabled": true, "responsive_web_graphql_timeline_navigation_enabled": true, - "responsive_web_grok_analysis_button_from_backend": true, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, + "premium_content_api_read_enabled": false, + "communities_web_enable_tweet_community_results_fetch": true, + "c9s_tweet_anatomy_moderator_badge_enabled": true, "responsive_web_grok_analyze_button_fetch_trends_enabled": false, "responsive_web_grok_analyze_post_followups_enabled": true, + "rweb_cashtags_composer_attachment_enabled": true, + "responsive_web_jetfuel_frame": true, + "responsive_web_grok_share_attachment_enabled": true, "responsive_web_grok_annotations_enabled": true, - "responsive_web_grok_community_note_auto_translation_is_enabled": false, + "articles_preview_enabled": true, + "responsive_web_edit_tweet_api_enabled": true, + "rweb_conversational_replies_downvote_enabled": false, + "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, + "view_counts_everywhere_api_enabled": true, + "longform_notetweets_consumption_enabled": true, + "responsive_web_twitter_article_tweet_consumption_enabled": true, + "content_disclosure_indicator_enabled": true, + "content_disclosure_ai_generated_indicator_enabled": true, + "responsive_web_grok_show_grok_translated_post": true, + "responsive_web_grok_analysis_button_from_backend": true, + "post_ctas_fetch_enabled": true, + "freedom_of_speech_not_reach_fetch_enabled": true, + "standardized_nudges_misinfo": true, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, + "longform_notetweets_rich_text_read_enabled": true, + "longform_notetweets_inline_media_enabled": false, "responsive_web_grok_image_annotation_enabled": true, "responsive_web_grok_imagine_annotation_enabled": true, - "responsive_web_grok_share_attachment_enabled": true, - "responsive_web_grok_show_grok_translated_post": false, - "responsive_web_jetfuel_frame": true, - "responsive_web_media_download_video_enabled": false, - "responsive_web_profile_redirect_enabled": false, - "responsive_web_text_conversations_enabled": false, - "responsive_web_twitter_article_notes_tab_enabled": false, - "responsive_web_twitter_article_tweet_consumption_enabled": true, - "responsive_web_twitter_blue_verified_badge_is_enabled": true, - "rweb_lists_timeline_redesign_enabled": true, - "rweb_tipjar_consumption_enabled": true, - "rweb_video_screen_enabled": false, - "rweb_video_timestamps_enabled": false, - "spaces_2022_h2_clipping": true, - "spaces_2022_h2_spaces_communities": true, - "standardized_nudges_misinfo": true, - "subscriptions_feature_can_gift_premium": false, - "subscriptions_verification_info_enabled": true, - "subscriptions_verification_info_is_identity_verified_enabled": false, - "subscriptions_verification_info_reason_enabled": true, - "subscriptions_verification_info_verified_since_enabled": true, - "super_follow_badge_privacy_enabled": false, - "super_follow_exclusive_tweet_notifications_enabled": false, - "super_follow_tweet_api_enabled": false, - "super_follow_user_api_enabled": false, - "tweet_awards_web_tipping_enabled": false, - "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, - "tweetypie_unmention_optimization_enabled": false, - "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, - "unified_cards_destination_url_params_enabled": false, - "verified_phone_label_enabled": false, - "vibe_api_enabled": false, - "view_counts_everywhere_api_enabled": true, - "hidden_profile_subscriptions_enabled": false + "responsive_web_grok_community_note_auto_translation_is_enabled": true, + "responsive_web_enhance_cards_enabled": false }""".replace(" ", "").replace("\n", "") tweetVars* = """{ diff --git a/src/parser.nim b/src/parser.nim index d55c546..902aebd 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -4,7 +4,7 @@ import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard -proc parseGraphTweet(js: JsonNode): Tweet +proc parseGraphTweet*(js: JsonNode): Tweet proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType = try: parseEnum[VerifiedType](s) @@ -46,10 +46,10 @@ proc parseUser(js: JsonNode; id=""): User = proc parseGraphUser(js: JsonNode): User = var user = js{"user_result", "result"} if user.isNull: - user = ? js{"user_results", "result"} + user = js{"user_results", "result"} if user.isNull: - if js{"core"}.notNull and js{"legacy"}.notNull: + if js{"core"}.notNull: user = js else: return @@ -61,6 +61,7 @@ proc parseGraphUser(js: JsonNode): User = # fallback to support UserMedia/recent GraphQL updates if result.username.len == 0: + result.id = user{"rest_id"}.getStr result.username = user{"core", "screen_name"}.getStr result.fullname = user{"core", "name"}.getStr result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") @@ -261,6 +262,20 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) = durationMs: mediaInfo{"duration_millis"}.getInt, variants: parseVideoVariants(mediaInfo{"variants"}) )) + + # Parse source user for video attribution + with sourceUser, mediaEntity{"source_user_results", "result"}: + if result.attribution.isNone: + let + expanded = mediaEntity{"expanded_url"}.getStr + pathStart = expanded.find('/', expanded.find("://") + 3) + if pathStart >= 0: + result.attributionLink = expanded[pathStart .. ^1].replace("/video/1", "") + result.attribution = some(User( + id: sourceUser{"rest_id"}.getStr, + fullname: sourceUser{"core", "name"}.getStr, + userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "") + )) of "ApiGif": parsedMedia.addMedia(Gif( url: mediaInfo{"variants"}[0]{"url"}.getImageStr, @@ -428,13 +443,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); # graphql with rt, js{"retweeted_status_result", "result"}: # needed due to weird edgecase where the actual tweet data isn't included - if "legacy" in rt: + if "legacy" in rt or "rest_id" in rt: result.retweet = some parseGraphTweet(rt) return with reposts, js{"repostedStatusResults"}: with rt, reposts{"result"}: - if "legacy" in rt: + if "legacy" in rt or "rest_id" in rt: result.retweet = some parseGraphTweet(rt) return @@ -449,7 +464,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); result.poll = some parsePoll(jsCard) elif name == "amplify": result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) - else: + elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"entities", "urls"}) result.expandTweetEntities(js) @@ -469,7 +484,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); result.text.removeSuffix(" Learn more.") result.available = false -proc parseGraphTweet(js: JsonNode): Tweet = +proc parseGraphTweet*(js: JsonNode): Tweet = if js.kind == JNull: return Tweet() @@ -537,10 +552,21 @@ proc parseGraphTweet(js: JsonNode): Tweet = result.poll = some parsePoll(jsCard) elif name == "amplify": result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) - else: + elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"url_entities"}) result.expandTweetEntitiesV2(js) + + # Strip video source URL from text (for videos from other tweets) + with mediaEntities, js{"media_entities"}: + for m in mediaEntities: + if "source_status_id_str" in m: + let mediaUrl = m{"url"}.getStr + if mediaUrl.len > 0: + let idx = result.text.rfind(mediaUrl) + if idx >= 0: + result.text = result.text[0 ..< idx].strip() + break else: result = parseTweet(js{"legacy"}, jsCard, replyId) result.id = js{"rest_id"}.getId @@ -559,6 +585,12 @@ proc parseGraphTweet(js: JsonNode): Tweet = parseMediaEntities(js, result) + # Handle retweets - check both legacy and top-level paths + with reposts, js{"legacy", "repostedStatusResults"}: + with rt, reposts{"result"}: + if "legacy" in rt or "rest_id" in rt: + result.retweet = some parseGraphTweet(rt) + with quoted, js{"quoted_status_result", "result"}: result.quote = some(parseGraphTweet(quoted)) From c956f7c37374721aafe28a62b22798f513b39c6f Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 5 Jun 2026 23:54:39 +0200 Subject: [PATCH 224/309] Add //about tests --- tests/test_about_account.py | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/test_about_account.py diff --git a/tests/test_about_account.py b/tests/test_about_account.py new file mode 100644 index 0000000..2239d9d --- /dev/null +++ b/tests/test_about_account.py @@ -0,0 +1,76 @@ +from base import BaseTestCase, Profile +from parameterized import parameterized + + +class AboutAccount(object): + header = '.about-account-header' + name = '.about-account-name' + body = '.about-account-body' + row = '.about-account-row' + label = '.about-account-label' + value = '.about-account-value' + + +# (username, expected_labels) +# Each label is checked for presence in the page text +about_data = [ + ['jack', ['Date joined', 'Account based in', 'Connected via']], + ['NASA', ['Date joined']], + ['elonmusk', ['Date joined']], +] + +about_verified = [ + ['jack', 'Verified', 'Since '], +] + +about_affiliate = [ + ['jack', 'An affiliate of', 'Square'], + ['elonmusk', 'An affiliate of', 'X'], +] + + +class AboutAccountTest(BaseTestCase): + @parameterized.expand(about_data) + def test_about_page_has_labels(self, username, expected_labels): + """About page shows expected info labels""" + self.open_nitter(f'{username}/about') + self.assert_element_visible(AboutAccount.header) + self.assert_element_visible(AboutAccount.body) + for label in expected_labels: + self.assert_text(label, AboutAccount.body) + + @parameterized.expand(about_verified) + def test_about_verified(self, username, label, value_prefix): + """About page shows verification info for verified accounts""" + self.open_nitter(f'{username}/about') + self.assert_text(label, AboutAccount.body) + self.assert_text(value_prefix, AboutAccount.body) + + @parameterized.expand(about_affiliate) + def test_about_affiliate(self, username, label, affiliate): + """About page shows affiliate info""" + self.open_nitter(f'{username}/about') + self.assert_text(label, AboutAccount.body) + self.assert_text(f'@{affiliate}', AboutAccount.body) + + def test_about_page_title(self): + """Title contains account name""" + self.open_nitter('jack/about') + self.assert_text('jack', AboutAccount.name) + + def test_about_join_date(self): + """About page always shows join date""" + self.open_nitter('jack/about') + self.assert_text('Date joined', AboutAccount.body) + self.assert_text('March 2006', AboutAccount.body) + + def test_about_invalid_user(self): + """About page for non-existent user shows error""" + self.open_nitter('thisprofiledoesntexist/about') + self.assert_text('User "thisprofiledoesntexist" not found') + + def test_joindate_links_to_about(self): + """Join date on profile page links to about page""" + self.open_nitter('jack') + link = self.find_element(Profile.joinDate + ' a') + self.assertIn('/jack/about', link.get_attribute('href')) From a86be15f852eadfd95b7441db97f1605a709e768 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 6 Jun 2026 00:14:28 +0200 Subject: [PATCH 225/309] Fix broken 2.2.x build --- .github/workflows/run-tests.yml | 8 ++++---- nitter.nimble | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c86087a..f70e8e5 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -33,9 +33,9 @@ jobs: uses: actions/cache@v5 with: path: ~/.nimble - key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} + key: ${{ matrix.nim }}-nimble-v3-${{ hashFiles('*.nimble') }} restore-keys: | - ${{ matrix.nim }}-nimble-v2- + ${{ matrix.nim }}-nimble-v3- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 @@ -104,9 +104,9 @@ jobs: uses: actions/cache@v5 with: path: ~/.nimble - key: 2.2.x-nimble-v2-${{ hashFiles('*.nimble') }} + key: 2.2.x-nimble-v3-${{ hashFiles('*.nimble') }} restore-keys: | - 2.2.x-nimble-v2- + 2.2.x-nimble-v3- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 diff --git a/nitter.nimble b/nitter.nimble index c206105..8e17353 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -18,7 +18,7 @@ requires "nimcrypto#a079df9" requires "markdown#158efe3" requires "packedjson#9e6fbb6" requires "supersnappy#6c94198" -requires "redpool#8b7c1db" +requires "https://github.com/zedeus/redpool#8b7c1db" requires "https://github.com/zedeus/redis#d0a0e6f" requires "zippy#ca5989a" requires "flatty#e668085" From 6ab2143df0fd2576b6e8ba7f96104a8b130325eb Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 7 Jun 2026 00:14:43 +0200 Subject: [PATCH 226/309] Add note about creating nitter.conf for Docker Fixes #1392 --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 05c2be4..c14cd23 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,17 @@ To run Nitter with Docker, you'll need to install and run Redis separately before you can run the container. See below for how to also run Redis using Docker. +First create your config file. The Docker commands mount it into the container, +so it has to exist on the host beforehand. If you've cloned the repo: + +```bash +cp nitter.example.conf nitter.conf +``` + +If you're using the prebuilt image without a local clone, download +[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf) +and save it as `nitter.conf` instead. + To build and run Nitter in Docker: ```bash @@ -151,8 +162,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run docker-compose up -d ``` -Note the Docker commands expect a `nitter.conf` file in the directory you run -them. +Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for +docker-compose) from the directory you run them in. If a mounted file doesn't +exist, Docker silently creates a directory in its place and the container fails +with `not a directory: Are you trying to mount a directory onto a file`. Remove +that directory and create the file as shown above. ### systemd From 40d17bf042900e385a4461a177f1983add150036 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 7 Jun 2026 00:43:29 +0200 Subject: [PATCH 227/309] Use Nim 2.2.6 for Docker builds, unify arm64 nimlang/nim's alpine-regular images cap at 2.2.6 and are now multi-arch, while Alpine's apk nim is stuck at the segfaulting 2.2.0. Base both arches on 2.2.6-alpine-regular, drop the separate Dockerfile.arm64, and build ./Dockerfile in the arm64 CI job. Fixes #1404 --- .github/workflows/build-docker.yml | 2 +- Dockerfile | 4 ++-- Dockerfile.arm64 | 25 ------------------------- 3 files changed, 3 insertions(+), 28 deletions(-) delete mode 100644 Dockerfile.arm64 diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 7a46257..9a6c490 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -55,7 +55,7 @@ jobs: uses: docker/build-push-action@v3 with: context: . - file: ./Dockerfile.arm64 + file: ./Dockerfile platforms: linux/arm64 push: true tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64 diff --git a/Dockerfile b/Dockerfile index ab442ba..251b63a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.2.0-alpine-regular as nim +FROM nimlang/nim:2.2.6-alpine-regular as nim LABEL maintainer="setenforce@protonmail.com" RUN apk --no-cache add libsass-dev pcre @@ -15,7 +15,7 @@ RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ FROM alpine:latest WORKDIR /src/ -RUN apk --no-cache add pcre ca-certificates +RUN apk --no-cache add pcre ca-certificates openssl COPY --from=nim /src/nitter/nitter ./ COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf COPY --from=nim /src/nitter/public ./public diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 deleted file mode 100644 index 46352c7..0000000 --- a/Dockerfile.arm64 +++ /dev/null @@ -1,25 +0,0 @@ -FROM alpine:3.20.6 as nim -LABEL maintainer="setenforce@protonmail.com" - -RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble - -WORKDIR /src/nitter - -COPY nitter.nimble . -RUN nimble install -y --depsOnly - -COPY . . -RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ - && nimble scss \ - && nimble md - -FROM alpine:3.20.6 -WORKDIR /src/ -RUN apk --no-cache add pcre ca-certificates openssl -COPY --from=nim /src/nitter/nitter ./ -COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf -COPY --from=nim /src/nitter/public ./public -EXPOSE 8080 -RUN adduser -h /src/ -D -s /bin/sh nitter -USER nitter -CMD ./nitter From f629507537707478c7a948760964b74b3ac54200 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 7 Jun 2026 01:02:47 +0200 Subject: [PATCH 228/309] Publish a single multi-arch Docker image Build amd64 and arm64 natively (no emulation), push each by digest, then merge them into one multi-arch manifest so `zedeus/nitter:latest` and `:` resolve to the right image on any CPU. Replaces the separate `latest-arm64` tag, which is no longer needed. Update the README notes accordingly. --- .github/workflows/build-docker.yml | 120 +++++++++++++++++++++-------- README.md | 4 +- 2 files changed, 88 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 9a6c490..f50cad7 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -7,55 +7,109 @@ on: branches: - master +env: + IMAGE: zedeus/nitter + jobs: tests: uses: ./.github/workflows/run-tests.yml secrets: inherit - build-docker-amd64: + # Build each architecture natively (no emulation) and push by digest only. + # The digests are stitched into a single multi-arch tag by the merge job. + build: needs: [tests] + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + platform: linux/amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + runs-on: ${{ matrix.runner }} + steps: + - name: Prepare platform name + run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + env: + platform: ${{ matrix.platform }} + + - uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: latest + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + # Attestations turn a single-platform push into a manifest index, which + # breaks push-by-digest + the imagetools merge below. Disable them. + provenance: false + sbom: false + + - name: Export digest + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # Combine the per-arch digests into one multi-arch manifest so that + # `docker pull zedeus/nitter:latest` serves the right image on any CPU. + merge: + needs: [build] runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v3 + - name: Download digests + uses: actions/download-artifact@v4 with: - version: latest - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and push AMD64 Docker image - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64 - push: true - tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }} + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true - build-docker-arm64: - needs: [tests] - runs-on: ubuntu-24.04-arm - steps: - - uses: actions/checkout@v6 - name: Set up Docker Buildx - id: buildx uses: docker/setup-buildx-action@v3 with: version: latest + - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and push ARM64 Docker image - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile - platforms: linux/arm64 - push: true - tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + # latest-arm64 is a backward-compat alias of the (now multi-arch) + # latest tag, for users still pinned to the old ARM64-only image. + # word splitting is intentional: one image ref arg per digest file + # shellcheck disable=SC2046 + docker buildx imagetools create \ + -t ${{ env.IMAGE }}:latest \ + -t ${{ env.IMAGE }}:latest-arm64 \ + -t ${{ env.IMAGE }}:${{ github.sha }} \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }} diff --git a/README.md b/README.md index c14cd23..eabd2ac 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ performance reasons. Page for the Docker image: https://hub.docker.com/r/zedeus/nitter -#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags). +#### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`. To run Nitter with Docker, you'll need to install and run Redis separately before you can run the container. See below for how to also run Redis using @@ -147,8 +147,6 @@ docker build -t nitter:latest . docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest ``` -Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64). - A prebuilt Docker image is provided as well: ```bash From a8bc1bbb2d0828b932f5a6198e94f07b98bc210d Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 7 Jun 2026 03:41:40 +0200 Subject: [PATCH 229/309] Fix nimble dependency caching in CI --- .github/workflows/build-docker.yml | 12 ++++-------- .github/workflows/run-tests.yml | 25 +++++++++++++++---------- nitter.nimble | 3 +-- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index f50cad7..20a15a0 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -7,6 +7,10 @@ on: branches: - master +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: true + env: IMAGE: zedeus/nitter @@ -15,8 +19,6 @@ jobs: uses: ./.github/workflows/run-tests.yml secrets: inherit - # Build each architecture natively (no emulation) and push by digest only. - # The digests are stitched into a single multi-arch tag by the merge job. build: needs: [tests] strategy: @@ -55,8 +57,6 @@ jobs: file: ./Dockerfile platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - # Attestations turn a single-platform push into a manifest index, which - # breaks push-by-digest + the imagetools merge below. Disable them. provenance: false sbom: false @@ -101,10 +101,6 @@ jobs: - name: Create manifest list and push working-directory: ${{ runner.temp }}/digests run: | - # latest-arm64 is a backward-compat alias of the (now multi-arch) - # latest tag, for users still pinned to the old ARM64-only image. - # word splitting is intentional: one image ref arg per digest file - # shellcheck disable=SC2046 docker buildx imagetools create \ -t ${{ env.IMAGE }}:latest \ -t ${{ env.IMAGE }}:latest-arm64 \ diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f70e8e5..dcfff52 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -32,10 +32,12 @@ jobs: id: cache-nimble uses: actions/cache@v5 with: - path: ~/.nimble - key: ${{ matrix.nim }}-nimble-v3-${{ hashFiles('*.nimble') }} + path: | + ~/.nimble/pkgcache + ~/.nimble/packages_official.json + key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }} restore-keys: | - ${{ matrix.nim }}-nimble-v3- + ${{ matrix.nim }}-nimble-v6- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 @@ -103,10 +105,12 @@ jobs: - name: Cache Nimble Dependencies uses: actions/cache@v5 with: - path: ~/.nimble - key: 2.2.x-nimble-v3-${{ hashFiles('*.nimble') }} + path: | + ~/.nimble/pkgcache + ~/.nimble/packages_official.json + key: 2.2.x-nimble-v6-${{ hashFiles('*.nimble') }} restore-keys: | - 2.2.x-nimble-v3- + 2.2.x-nimble-v6- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 @@ -115,6 +119,9 @@ jobs: use-nightlies: true repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Nimble dependencies + run: nimble install -y --depsOnly + - name: Download 2.2.x build artifact uses: actions/download-artifact@v4 with: @@ -130,10 +137,8 @@ jobs: sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf - # Run both Nimble tasks concurrently - nim r tools/rendermd.nim & - nim r tools/gencss.nim & - wait + nim r tools/rendermd.nim + nim r tools/gencss.nim echo '${{ secrets.SESSIONS }}' | head -n1 echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl diff --git a/nitter.nimble b/nitter.nimble index 8e17353..ba0a8c2 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -18,8 +18,7 @@ requires "nimcrypto#a079df9" requires "markdown#158efe3" requires "packedjson#9e6fbb6" requires "supersnappy#6c94198" -requires "https://github.com/zedeus/redpool#8b7c1db" -requires "https://github.com/zedeus/redis#d0a0e6f" +requires "redpool >= 0.2.0" requires "zippy#ca5989a" requires "flatty#e668085" requires "jsony#1de1f08" From 55d067957c7fbb5bcf88388bd165e6c6eab08be9 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 7 Jun 2026 23:17:43 +0200 Subject: [PATCH 230/309] Fix arm32 compilation Fixes #1389 --- src/parser.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parser.nim b/src/parser.nim index 902aebd..c34ffa9 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -521,7 +521,7 @@ proc parseGraphTweet*(js: JsonNode): Tweet = "binding_values": %bindingObj } - var replyId = 0 + var replyId: int64 = 0 with restId, js{"reply_to_results", "rest_id"}: replyId = restId.getId From 9b9c86a15c24874262b0b70441d5b7a05cca0158 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 8 Jun 2026 00:47:42 +0200 Subject: [PATCH 231/309] Use Nimble local deps to fix deployment issue Fixes #1405 --- .gitignore | 3 +++ README.md | 6 +++--- config.nims | 4 ++++ nitter.nimble | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 09bdaa4..2e52163 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ sessions.json* dump.rdb *.bak /tools/*.json* +nimbledeps/ +nimble.paths +nimble.develop diff --git a/README.md b/README.md index eabd2ac..0b0a14f 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,9 @@ along with the scss and md files. # su nitter $ git clone https://github.com/zedeus/nitter $ cd nitter -$ nimble build -d:danger --mm:refc -$ nimble scss -$ nimble md +$ nimble -l build -d:danger --mm:refc +$ nimble -l scss +$ nimble -l md $ cp nitter.example.conf nitter.conf ``` diff --git a/config.nims b/config.nims index 4a7af27..3ee4842 100644 --- a/config.nims +++ b/config.nims @@ -11,3 +11,7 @@ warning("HoleEnumConv", off) hint("XDeclaredButNotUsed", off) hint("XCannotRaiseY", off) hint("User", off) +# begin Nimble config (version 2) +when withDir(thisDir(), system.fileExists("nimble.paths")): + include "nimble.paths" +# end Nimble config diff --git a/nitter.nimble b/nitter.nimble index ba0a8c2..90ec7b0 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -18,7 +18,7 @@ requires "nimcrypto#a079df9" requires "markdown#158efe3" requires "packedjson#9e6fbb6" requires "supersnappy#6c94198" -requires "redpool >= 0.2.0" +requires "redpool == 0.2.2" requires "zippy#ca5989a" requires "flatty#e668085" requires "jsony#1de1f08" From ef1de42593d6a2f0ef2ee0bbf5800605a0a35fe7 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 8 Jun 2026 06:12:52 +0200 Subject: [PATCH 232/309] Update dependencies to version tags --- nitter.nimble | 20 ++++++++++---------- src/apiutils.nim | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/nitter.nimble b/nitter.nimble index 90ec7b0..b36f498 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -11,18 +11,18 @@ bin = @["nitter"] # Dependencies requires "nim >= 2.0.0" -requires "jester#baca3f" -requires "karax#5cf360c" -requires "sass#7dfdd03" -requires "nimcrypto#a079df9" -requires "markdown#158efe3" +requires "jester == 0.6.0" +requires "karax == 1.5.0" +requires "sass == 0.2.0" +requires "nimcrypto == 0.7.3" +requires "markdown == 0.8.8" requires "packedjson#9e6fbb6" -requires "supersnappy#6c94198" +requires "supersnappy == 2.1.4" requires "redpool == 0.2.2" -requires "zippy#ca5989a" -requires "flatty#e668085" -requires "jsony#1de1f08" -requires "oauth#b8c163b" +requires "zippy == 0.10.19" +requires "flatty == 0.4.0" +requires "jsony == 1.1.6" +requires "oauth == 0.11" # Tasks diff --git a/src/apiutils.nim b/src/apiutils.nim index bf6c4b9..6ceab6f 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-only import httpclient, asyncdispatch, options, strutils, uri, times, math, tables -import jsony, packedjson, zippy, oauth1 +import jsony, packedjson, zippy, oauth/oauth1 import types, auth, consts, parserutils, http_pool, tid import experimental/types/common From 60cb10229fe18c5f6b59e6d4e554a86b884ea7bb Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 8 Jun 2026 06:12:58 +0200 Subject: [PATCH 233/309] Fix SameSite cookie handling for HTTP --- src/routes/router_utils.nim | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index 379280c..8a26193 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -8,8 +8,9 @@ export utils, prefs, types, uri template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): + let sameSite = if cfg.useHttps: None else: Lax setCookie(pref, value, daysForward(when expire: -10 else: 360), - httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/") + httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/") template requestPrefs*(): untyped {.dirty.} = getPrefs(cookies(request), params(request)) From fb9107cff6d00be2cac1acd5945b5088329b46e9 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 8 Jun 2026 06:14:18 +0200 Subject: [PATCH 234/309] Fix crash on malformed request paths --- src/nitter.nim | 9 +++++-- tests/test_security.py | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tests/test_security.py diff --git a/src/nitter.nim b/src/nitter.nim index d1c0ef1..685b608 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -2,7 +2,7 @@ import asyncdispatch, strformat, logging from net import Port from htmlgen import a -from os import getEnv +from os import getEnv, normalizedPath import jester @@ -63,12 +63,17 @@ createDebugRouter(cfg) settings: port = Port(cfg.port) - staticDir = cfg.staticDir + staticDir = normalizedPath(cfg.staticDir) bindAddr = cfg.address reusePort = true + maxBody = 64 * 1024 routes: before: + # Reject malformed paths + if request.path.len == 0 or request.path[0] != '/': + halt Http400 + # skip all file URLs cond "." notin request.path applyUrlPrefs() diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..65d5e9a --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,60 @@ +import subprocess +from parameterized import parameterized + +BASE_URL = 'http://localhost:8080' + + +def curl_status(url): + """Get HTTP status code using curl to avoid URL normalization by Python libs.""" + result = subprocess.run( + ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', url], + capture_output=True, text=True, timeout=10 + ) + return int(result.stdout) + + +class TestMalformedPaths: + """Test that malformed paths don't crash the server. + + URLs like //foo are parsed as having 'foo' as the authority (host), + resulting in an empty path. Empty paths previously crashed jester's + static file handler. Now they return 400. + + URLs like //foo/bar are parsed as authority='foo', path='/bar', + so they route normally (not empty path). + """ + + @parameterized.expand([ + # These parse to empty paths -> 400 + ('//lefty_rae', 400), + ('//test', 400), + ('//anyuser', 400), + ]) + def test_empty_path_returns_400(self, path, expected_status): + """URLs that parse to empty paths should return 400, not crash.""" + status = curl_status(f'{BASE_URL}{path}') + assert status == expected_status, \ + f'Expected {expected_status} for {path}, got {status}' + + @parameterized.expand([ + ('/jack', 200), + ('/about', 200), + ('/', 200), + ]) + def test_normal_paths_work(self, path, expected_status): + """Normal paths should still work.""" + status = curl_status(f'{BASE_URL}{path}') + assert status == expected_status, \ + f'Expected {expected_status} for {path}, got {status}' + + def test_server_survives_malformed_requests(self): + """Server should handle malformed requests without crashing.""" + # These all parse to empty paths + malformed_paths = ['//a', '//b', '//c', '//user', '//test'] + for path in malformed_paths: + status = curl_status(f'{BASE_URL}{path}') + assert status == 400, f'Expected 400 for {path}, got {status}' + + # Verify server is still responding after malformed requests + status = curl_status(f'{BASE_URL}/') + assert status == 200, 'Server should still be alive' From 5a4c8dd12c616958b59dfd9259fcb7befc9b3ff9 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 8 Jun 2026 23:59:50 +0200 Subject: [PATCH 235/309] Improve retry failure handling Fixes #1388 --- src/apiutils.nim | 4 ++++ src/redis_cache.nim | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 6ceab6f..5fbd17c 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -182,10 +182,12 @@ template fetchImpl(result, fetchBody) {.dirty.} = template retry(bod) {.dirty.} = var session: Session + var retrySuccess = false for i in 0 ..< maxRetries: try: session = nil bod + retrySuccess = true break except RateLimitError: let api = if session.isNil: req.cookie.endpoint @@ -199,6 +201,8 @@ template retry(bod) {.dirty.} = session = nil if retryDelayMs > 0: await sleepAsync(retryDelayMs) + if not retrySuccess: + raise rateLimitError() proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = retry: diff --git a/src/redis_cache.nim b/src/redis_cache.nim index bfd271f..e503e46 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -144,9 +144,10 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = else: let user = await getGraphUserById(userId) result = user.username - await setEx(key, baseCacheTime, result) - if result.len > 0 and user.id.len > 0: - await all(cacheUserId(result, user.id), cache(user)) + if result.len > 0: + await setEx(key, baseCacheTime, result) + if user.id.len > 0: + await all(cacheUserId(result, user.id), cache(user)) # proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = # if id == 0: return From 1e33ca045d199e84aaebc965ed54a8dfd719079b Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 10 Jun 2026 06:20:01 +0200 Subject: [PATCH 236/309] Fix restIdVars whitespace handling --- src/consts.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/consts.nim b/src/consts.nim index f903528..beefa57 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -104,7 +104,7 @@ const restIdVars* = """{ "rest_id": "$1", $2 "count": $3 -}""" +}""".replace(" ", "").replace("\n", "") userMediaVars* = """{ "userId": "$1", $2 From ac2f93b3619ed7604539f0bf1c5dc132cfd38b7f Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 10 Jun 2026 06:20:05 +0200 Subject: [PATCH 237/309] Add skipTid field for cookie session TID handling --- src/api.nim | 15 +++++---------- src/apiutils.nim | 9 ++++++--- src/types.nim | 1 + 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/api.nim b/src/api.nim index c66ca3a..404a01e 100644 --- a/src/api.nim +++ b/src/api.nim @@ -11,11 +11,11 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = if fieldToggles.len > 0: result.add ("fieldToggles", fieldToggles) -proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl = - return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles)) +proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl = + return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid) -proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq = - let url = apiUrl(endpoint, variables, fieldToggles) +proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq = + let url = apiUrl(endpoint, variables, fieldToggles, skipTid) return ApiReq(cookie: url, oauth: url) proc mediaUrl(id, cursor: string; count=20): ApiReq = @@ -32,12 +32,7 @@ proc userTweetsUrl(id: string; cursor: string): ApiReq = # ) proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = - return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"]) - #let cookieVars = userTweetsAndRepliesVars % [id, cursor] - # result = ApiReq( - # cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles), - # oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"]) - # ) + return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], skipTid=true) proc tweetDetailUrl(id: string; cursor: string): ApiReq = return apiReq(graphTweet, tweetVars % [id, cursor]) diff --git a/src/apiutils.nim b/src/apiutils.nim index 5fbd17c..8e1a852 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -63,7 +63,7 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getCookieHeader(authToken, ct0: string): string = "auth_token=" & authToken & "; ct0=" & ct0 -proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = +proc genHeaders*(session: Session, url: Uri, skipTid: bool): Future[HttpHeaders] {.async.} = result = newHttpHeaders({ "accept": "*/*", "accept-encoding": "gzip", @@ -91,7 +91,7 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result["sec-fetch-dest"] = "empty" result["sec-fetch-mode"] = "cors" result["sec-fetch-site"] = "same-origin" - if disableTid or "/1.1/" in url.path: + if disableTid or skipTid or "/1.1/" in url.path: result["authorization"] = bearerToken2 else: result["authorization"] = bearerToken @@ -115,7 +115,10 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - let headers = await genHeaders(session, url) + let skipTid = case session.kind + of oauth: req.oauth.skipTid + of cookie: req.cookie.skipTid + let headers = await genHeaders(session, url, skipTid) pool.use(headers): template getContent = diff --git a/src/types.nim b/src/types.nim index c755589..3604efb 100644 --- a/src/types.nim +++ b/src/types.nim @@ -16,6 +16,7 @@ type ApiUrl* = object endpoint*: string params*: seq[(string, string)] + skipTid*: bool ApiReq* = object oauth*: ApiUrl From 63891a04ff5154dd471869325b2acb5d54c4d376 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 10 Jun 2026 06:20:08 +0200 Subject: [PATCH 238/309] Update tests --- tests/test_card.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_card.py b/tests/test_card.py index f0a916c..16863ea 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -14,7 +14,7 @@ card = [ 'gist.github.com', True], ['NASA/status/2061872347477418301', - 'Nancy Grace Roman Space Telescope Mission - NASA Science', + 'Nancy Grace Roman Space Telescope - NASA Science', 'The Nancy Grace Roman Space Telescope will settle essential questions in the areas of dark energy, exoplanets, and astrophysics.', 'science.nasa.gov', True] ] From ac5ba9469eb52cf310a2cca9828351a3ab598a37 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 10 Jun 2026 14:28:01 +0200 Subject: [PATCH 239/309] Fix incorrectly shown Twitter video cards Fixes #1407 --- src/parser.nim | 28 +++++++++++++++++++++------- src/parserutils.nim | 11 ++++++++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index c34ffa9..097d316 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, options, times, math, tables +import strutils, options, times, math, tables, uri import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard @@ -229,6 +229,10 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = result.attribution = some(parseUser(user)) else: result.attribution = some(parseGraphUser(user)) + # Set attribution link from expanded_url (strip /video/N suffix) + let expanded = m{"expanded_url"}.getStr + if expanded.len > 0: + result.attributionLink = expanded.parseUri.path.replace("/video/1", "") of "animated_gif": result.media.addMedia(Gif( url: m{"video_info", "variants"}[0]{"url"}.getImageStr, @@ -266,11 +270,9 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) = # Parse source user for video attribution with sourceUser, mediaEntity{"source_user_results", "result"}: if result.attribution.isNone: - let - expanded = mediaEntity{"expanded_url"}.getStr - pathStart = expanded.find('/', expanded.find("://") + 3) - if pathStart >= 0: - result.attributionLink = expanded[pathStart .. ^1].replace("/video/1", "") + let expanded = mediaEntity{"expanded_url"}.getStr + if expanded.len > 0: + result.attributionLink = expanded.parseUri.path.replace("/video/1", "") result.attribution = some(User( id: sourceUser{"rest_id"}.getStr, fullname: sourceUser{"core", "name"}.getStr, @@ -467,8 +469,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"entities", "urls"}) - result.expandTweetEntities(js) parseLegacyMediaEntities(js, result) + result.expandTweetEntities(js) with jsWithheld, js{"withheld_in_countries"}: let withheldInCountries: seq[string] = @@ -555,6 +557,10 @@ proc parseGraphTweet*(js: JsonNode): Tweet = elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"url_entities"}) + parseMediaEntities(js, result) + if result.attribution.isNone: + parseLegacyMediaEntities(js{"legacy"}, result) + result.expandTweetEntitiesV2(js) # Strip video source URL from text (for videos from other tweets) @@ -585,6 +591,14 @@ proc parseGraphTweet*(js: JsonNode): Tweet = parseMediaEntities(js, result) + # Hide card if it's redundant with attribution (same video shown via embed) + if result.attribution.isSome and result.card.isSome: + let cardUri = get(result.card).url.parseUri + if cardUri.isTwitterUrl: + let cardPath = cardUri.path.replace("/video/1", "") + if cardPath.len > 0 and cardPath == result.attributionLink: + get(result.card).kind = hidden + # Handle retweets - check both legacy and top-level paths with reposts, js{"legacy", "repostedStatusResults"}: with rt, reposts{"result"}: diff --git a/src/parserutils.nim b/src/parserutils.nim index 8d6ea2e..bb20425 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -319,6 +319,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = textSlice = textRange{0}.getInt .. textRange{1}.getInt hasQuote = js{"is_quote_status"}.getBool hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails + hasAttribution = tweet.attribution.isSome var replyTo = "" if tweet.replyId != 0: @@ -326,7 +327,8 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = replyTo = reply.getStr tweet.reply.add replyTo - tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) + tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, + hasQuote or hasJobCard or hasAttribution) proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; hasRedundantLink=false) = @@ -377,16 +379,19 @@ proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) = textSlice = textRange{0}.getInt .. textRange{1}.getInt hasQuote = "quoted_tweet_results" in js hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails + hasAttribution = tweet.attribution.isSome - tweet.expandTextEntitiesV2(js, tweet.text, textSlice, hasQuote or hasJobCard) + tweet.expandTextEntitiesV2(js, tweet.text, textSlice, + hasQuote or hasJobCard or hasAttribution) proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entity_set"} text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose)) textSlice = 0..text.runeLen + hasAttribution = tweet.attribution.isSome - tweet.expandTextEntities(entities, text, textSlice) + tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution) tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) From 7eed720894abf3496b9db3c7071be821fb40a7c7 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 10 Jun 2026 14:54:36 +0200 Subject: [PATCH 240/309] Fix entity expansion crash, simplify URL handling Fixes #1409 --- src/parser.nim | 21 +-------------------- src/parserutils.nim | 3 +-- temp | 0 3 files changed, 2 insertions(+), 22 deletions(-) create mode 100644 temp diff --git a/src/parser.nim b/src/parser.nim index 097d316..78d40f7 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -241,11 +241,6 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = )) else: discard - with url, m{"url"}: - if result.text.endsWith(url.getStr): - result.text.removeSuffix(url.getStr) - result.text = result.text.strip() - proc parseMediaEntities(js: JsonNode; result: var Tweet) = with mediaEntities, js{"media_entities"}: var parsedMedia: MediaEntities @@ -286,23 +281,9 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) = )) else: discard - if "expanded_url" in mediaEntity: - let expandedUrl = js.getExpandedUrl - if result.text.endsWith(expandedUrl): - result.text.removeSuffix(expandedUrl) - result.text = result.text.strip() - if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: result.media = parsedMedia - # Remove media URLs from text - with mediaList, js{"legacy", "entities", "media"}: - for url in mediaList: - let expandedUrl = url.getExpandedUrl - if result.text.endsWith(expandedUrl): - result.text.removeSuffix(expandedUrl) - result.text = result.text.strip() - proc parsePromoVideo(js: JsonNode): Video = result = Video( thumb: js{"player_image_large"}.getImageVal, @@ -469,8 +450,8 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); elif name.len > 0 and jsCard{"binding_values"}.notNull: result.card = some parseCard(jsCard, js{"entities", "urls"}) - parseLegacyMediaEntities(js, result) result.expandTweetEntities(js) + parseLegacyMediaEntities(js, result) with jsWithheld, js{"withheld_in_countries"}: let withheldInCountries: seq[string] = diff --git a/src/parserutils.nim b/src/parserutils.nim index bb20425..e7479d5 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -319,7 +319,6 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = textSlice = textRange{0}.getInt .. textRange{1}.getInt hasQuote = js{"is_quote_status"}.getBool hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails - hasAttribution = tweet.attribution.isSome var replyTo = "" if tweet.replyId != 0: @@ -328,7 +327,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = tweet.reply.add replyTo tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, - hasQuote or hasJobCard or hasAttribution) + hasQuote or hasJobCard) proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; hasRedundantLink=false) = diff --git a/temp b/temp new file mode 100644 index 0000000..e69de29 From bd9d492d3619916c86036f7b9a1b8ed02d8e8905 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 11 Jun 2026 23:26:21 +0200 Subject: [PATCH 241/309] Fix bounds checking Fixes #1410 --- src/formatters.nim | 18 +++++++++++++----- src/parserutils.nim | 31 ++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index aef1c12..958e518 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -140,25 +140,30 @@ proc pageDesc*(user: User): string = "The latest tweets from " & user.fullname proc getJoinDate*(user: User): string = + if user.joinDate.year == 0: return "" user.joinDate.format("'Joined' MMMM YYYY") proc getJoinDateFull*(user: User): string = + if user.joinDate.year == 0: return "" user.joinDate.format("h:mm tt - d MMM YYYY") proc getTime*(tweet: Tweet): string = + if tweet.time.year == 0: return "" tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'") proc getRfc822Time*(tweet: Tweet): string = + if tweet.time.year == 0: return "" tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'") -proc getShortTime*(tweet: Tweet): string = +proc getShortTime*(time: DateTime): string = + if time.year == 0: return "" let now = now() - let since = now - tweet.time + let since = now - time - if now.year != tweet.time.year: - result = tweet.time.format("d MMM yyyy") + if now.year != time.year: + result = time.format("d MMM yyyy") elif since.inDays >= 1: - result = tweet.time.format("MMM d") + result = time.format("MMM d") elif since.inHours >= 1: result = $since.inHours & "h" elif since.inMinutes >= 1: @@ -168,6 +173,9 @@ proc getShortTime*(tweet: Tweet): string = else: result = "now" +proc getShortTime*(tweet: Tweet): string = + getShortTime(tweet.time) + proc getDuration*(ms: int): string = let sec = int(round(ms / 1000)) diff --git a/src/parserutils.nim b/src/parserutils.nim index e7479d5..860c9d2 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -202,19 +202,32 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) = proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; textSlice: Slice[int]): string = + let + runeLen = runes.len + safeStart = max(0, textSlice.a) + safeEnd = min(runeLen, textSlice.b) + + var validRepls: seq[ReplaceSlice] + for rep in repls: + if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b: + validRepls.add rep + template extractLowerBound(i: int; idx): int = - if i > 0: repls[idx].slice.b.succ else: textSlice.a + if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart result = newStringOfCap(runes.len) - for i, rep in repls: - result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] + for i, rep in validRepls: + let lower = extractLowerBound(i, i - 1) + if lower < rep.slice.a: + result.add $runes[lower ..< rep.slice.a] case rep.kind of rkHashtag: - let - name = $runes[rep.slice.a.succ .. rep.slice.b] - symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) + if rep.slice.a.succ <= rep.slice.b: + let + name = $runes[rep.slice.a.succ .. rep.slice.b] + symbol = $runes[rep.slice.a] + result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: result.add a($runes[rep.slice], href = rep.url, title = rep.display) of rkUrl: @@ -222,8 +235,8 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; of rkRemove: discard - let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b - if rest.a <= rest.b: + let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd + if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen: result.add $runes[rest] proc deduplicate(s: var seq[ReplaceSlice]) = From 35882ed88d422b1355b66a1ff8c1144bffdc7bdf Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 16 Jun 2026 14:37:23 +0200 Subject: [PATCH 242/309] Add Twitter Article rendering Fixes #646 --- src/api.nim | 25 ++- src/consts.nim | 25 ++- src/experimental/parser.nim | 4 +- src/experimental/parser/article.nim | 87 ++++++++ src/experimental/types/article.nim | 78 +++++++ src/nitter.nim | 4 +- src/parser.nim | 23 ++- src/parserutils.nim | 18 +- src/routes/article.nim | 48 +++++ src/sass/_article.scss | 272 ++++++++++++++++++++++++ src/sass/index.scss | 1 + src/types.nim | 44 ++++ src/views/article.nim | 246 ++++++++++++++++++++++ src/views/general.nim | 9 +- src/views/tweet.nim | 25 ++- tests/test_article.py | 307 ++++++++++++++++++++++++++++ 16 files changed, 1192 insertions(+), 24 deletions(-) create mode 100644 src/experimental/parser/article.nim create mode 100644 src/experimental/types/article.nim create mode 100644 src/routes/article.nim create mode 100644 src/sass/_article.scss create mode 100644 src/views/article.nim create mode 100644 tests/test_article.py diff --git a/src/api.nim b/src/api.nim index 404a01e..2702f7c 100644 --- a/src/api.nim +++ b/src/api.nim @@ -2,7 +2,7 @@ import asyncdispatch, httpclient, strutils, sequtils, sugar import packedjson import types, query, formatters, consts, apiutils, parser, utils -import experimental/parser as newParser +import experimental/parser # Helper to generate params object for GraphQL requests proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = @@ -25,14 +25,10 @@ proc mediaUrl(id, cursor: string; count=20): ApiReq = ) proc userTweetsUrl(id: string; cursor: string): ApiReq = - return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"]) - # result = ApiReq( - # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), - # oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"]) - # ) + return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles) proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = - return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], skipTid=true) + return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true) proc tweetDetailUrl(id: string; cursor: string): ApiReq = return apiReq(graphTweet, tweetVars % [id, cursor]) @@ -228,6 +224,21 @@ proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = let js = await fetch(mediaUrl(id, "", 30)) result = parseGraphPhotoRail(js) +proc getGraphArticle*(id: string): Future[Article] {.async.} = + if id.len == 0: return + let + url = apiReq(graphTweetResultByRestId, articleVars % id, articleFieldToggles) + json = await fetchRaw(url) + result = parseGraphArticle(json) + +proc getGraphTweetResults*(ids: seq[string]): Future[seq[Tweet]] {.async.} = + if ids.len == 0: return + let + idsJson = "[" & ids.mapIt("\"" & it & "\"").join(",") & "]" + url = apiReq(graphTweetResultsByRestIds, articleBatchVars % idsJson, articleFieldToggles) + js = await fetch(url) + result = parseGraphTweetResults(js) + proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) try: diff --git a/src/consts.nim b/src/consts.nim index beefa57..5ac3c32 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -10,7 +10,7 @@ const graphUser* = "IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName" graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery" graphUserById* = "-DAaa9jPxPswYeI2fZ9rug/UserResultByIdQuery" - graphUserTweetsV2* = "PHTSTXqZYuHIeK4B1HQprQ/UserWithProfileTweetsQueryV2" + graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2" graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2" graphUserTweets* = "PNd0vlufvrcIwrAnBYKE9g/UserTweets" graphUserTweetsAndReplies* = "EqtpEwt0CoQXmDfq5DKH0A/UserTweetsAndReplies" @@ -28,6 +28,9 @@ const graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline" graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery" + graphTweetResultByRestId* = "qtXMy1p5Y62uCskc_NUPJw/TweetResultByRestId" + graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds" + graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery" restLiveStream* = "1.1/live_video_stream/status/" @@ -131,6 +134,24 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") + articleVars* = """{ + "tweetId": "$1", + "includePromotedContent": false, + "withBirdwatchNotes": true, + "withVoice": true, + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + articleBatchVars* = """{ + "tweetIds": $1, + "includePromotedContent": false, + "withBirdwatchNotes": true, + "withVoice": true, + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}""" + userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}""" - userTweetsFieldToggles* = """{"withArticlePlainText":false}""" + userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" diff --git a/src/experimental/parser.nim b/src/experimental/parser.nim index 40986f5..e22a51f 100644 --- a/src/experimental/parser.nim +++ b/src/experimental/parser.nim @@ -1,2 +1,2 @@ -import parser/[user, graphql] -export user, graphql +import parser/[user, graphql, article] +export user, graphql, article diff --git a/src/experimental/parser/article.nim b/src/experimental/parser/article.nim new file mode 100644 index 0000000..2ae7f4a --- /dev/null +++ b/src/experimental/parser/article.nim @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import std/[strutils, tables, times, options] +import jsony +import utils, graphql, ../types/article +from ../../types import Article, ArticleParagraph, ArticleEntity, ArticleMedia, + User, TweetStats + +proc parseGraphArticle*(json: string): Article = + if json.len == 0 or json[0] != '{': + return + + var raw: GraphArticle + try: + raw = json.fromJson(GraphArticle) + except CatchableError: + return + + let + tweet = raw.data.tweetResult.result + article = tweet.article.articleResults.result + + if article.title.len == 0: + return + + let publishedAt = article.metadata.firstPublishedAtSecs + var articleTime: DateTime + if publishedAt > 0: + articleTime = publishedAt.int64.fromUnix.utc + elif tweet.legacy.createdAt.len > 0: + articleTime = parseTwitterDate(tweet.legacy.createdAt) + + result = Article( + title: article.title, + coverImage: getImageUrl(article.coverMedia.mediaInfo.originalImgUrl), + time: articleTime, + user: parseUserResult(tweet.core.userResults.result), + ) + + result.stats = TweetStats( + replies: tweet.legacy.replyCount, + retweets: tweet.legacy.retweetCount, + likes: tweet.legacy.favoriteCount, + ) + if tweet.views.count.len > 0: + try: result.stats.views = parseInt(tweet.views.count) + except ValueError: discard + + for blk in article.contentState.blocks: + result.paragraphs.add ArticleParagraph( + text: blk.text, + kind: blk.blockKind, + inlineStyles: blk.inlineStyleRanges, + entityRanges: blk.entityRanges, + ) + + for entry in article.contentState.entityMap: + let key = try: parseInt(entry.key) except ValueError: continue + var entity = ArticleEntity(kind: entry.value.entityKind) + case entity.kind + of "LINK": entity.url = entry.value.data.url + of "MEDIA": + for mi in entry.value.data.mediaItems: + entity.mediaIds.add mi.mediaId + of "TWEET": entity.tweetId = entry.value.data.tweetId + of "MARKDOWN": entity.markdown = entry.value.data.markdown + else: discard + result.entities[key] = entity + + for me in article.mediaEntities: + let typeName = me.mediaInfo.typeName + var media = ArticleMedia(kind: typeName) + if me.mediaInfo.videoInfo.isSome: + let variants = me.mediaInfo.videoInfo.get.variants + case typeName + of "ApiGif": + if variants.len > 0: + media.url = variants[0].url + of "ApiVideo": + var bestBitrate = -1 + for v in variants: + if v.bitrate > bestBitrate: + bestBitrate = v.bitrate + media.url = v.url + else: discard + elif typeName == "ApiImage": + media.url = getImageUrl(me.mediaInfo.originalImgUrl) + result.media[me.mediaId] = media diff --git a/src/experimental/types/article.nim b/src/experimental/types/article.nim new file mode 100644 index 0000000..024c69f --- /dev/null +++ b/src/experimental/types/article.nim @@ -0,0 +1,78 @@ +import std/options +import graphuser +from ../../types import ArticleStyle, ArticleEntityRange + +type + GraphArticle* = object + data*: tuple[tweetResult: tuple[result: TweetResultNode]] + + TweetResultNode* = object + article*: tuple[articleResults: tuple[result: ArticleResultNode]] + legacy*: TweetLegacy + core*: tuple[userResults: UserData] + views*: tuple[count: string] + + TweetLegacy* = object + createdAt*: string + replyCount*: int + retweetCount*: int + favoriteCount*: int + + ArticleResultNode* = object + title*: string + coverMedia*: tuple[mediaInfo: MediaInfoNode] + contentState*: ContentState + metadata*: tuple[firstPublishedAtSecs: int] + mediaEntities*: seq[RawMediaEntity] + + ContentState* = object + blocks*: seq[ContentBlock] + entityMap*: seq[EntityMapEntry] + + ContentBlock* = object + text*: string + blockKind*: string + inlineStyleRanges*: seq[ArticleStyle] + entityRanges*: seq[ArticleEntityRange] + + EntityMapEntry* = object + key*: string + value*: EntityMapValue + + EntityMapValue* = object + entityKind*: string + data*: EntityDataNode + + EntityDataNode* = object + url*: string + mediaItems*: seq[tuple[mediaId: string]] + tweetId*: string + markdown*: string + + RawMediaEntity* = object + mediaId*: string + mediaInfo*: MediaInfoNode + + MediaInfoNode* = object + typeName*: string + originalImgUrl*: string + videoInfo*: Option[VideoInfoNode] + + VideoInfoNode* = object + variants*: seq[VideoVariant] + + VideoVariant* = object + url*: string + bitrate*: int + +proc renameHook*(v: var ContentBlock; fieldName: var string) = + if fieldName == "type": + fieldName = "blockKind" + +proc renameHook*(v: var EntityMapValue; fieldName: var string) = + if fieldName == "type": + fieldName = "entityKind" + +proc renameHook*(v: var MediaInfoNode; fieldName: var string) = + if fieldName == "__typename": + fieldName = "typeName" diff --git a/src/nitter.nim b/src/nitter.nim index 685b608..78cace4 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, debug, - unsupported, embed, resolver, broadcast, router_utils] + unsupported, embed, resolver, broadcast, article, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -48,6 +48,7 @@ waitFor initRedisPool(cfg) stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" stdout.flushFile +createArticleRouter(cfg) createUnsupportedRouter(cfg) createResolverRouter(cfg) createPrefRouter(cfg) @@ -118,6 +119,7 @@ routes: resp Http429, showError( &"Instance has no auth tokens, or is fully rate limited.
Use {link} or try again later.", cfg) + extend articleRoute, "" extend rss, "" extend status, "" extend search, "" diff --git a/src/parser.nim b/src/parser.nim index 78d40f7..d7b3f8d 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -542,7 +542,8 @@ proc parseGraphTweet*(js: JsonNode): Tweet = if result.attribution.isNone: parseLegacyMediaEntities(js{"legacy"}, result) - result.expandTweetEntitiesV2(js) + let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0 + result.expandTweetEntitiesV2(js, hasArticle) # Strip video source URL from text (for videos from other tweets) with mediaEntities, js{"media_entities"}: @@ -558,6 +559,16 @@ proc parseGraphTweet*(js: JsonNode): Tweet = result = parseTweet(js{"legacy"}, jsCard, replyId) result.id = js{"rest_id"}.getId + with artNode, js{"article", "article_results", "result"}: + let artTitle = artNode{"title"}.getStr + if artTitle.len > 0: + result.articlePreview = some ArticlePreview( + title: artTitle, + previewText: artNode{"preview_text"}.getStr, + coverImage: artNode{"cover_media_results", "result", "media_info", "original_img_url"}.getImageStr, + tweetId: result.id + ) + result.user = parseGraphUser(js{"core"}) if result.reply.len == 0: @@ -627,6 +638,16 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet) +proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] = + let results = js{"data", "tweetResult"} + if results.kind != JArray: return + for item in results: + let tweet = item{"result"} + if tweet.isNull: continue + let t = parseGraphTweet(tweet) + if t != nil: + result.add t + proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) diff --git a/src/parserutils.nim b/src/parserutils.nim index 860c9d2..07aa088 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -185,12 +185,16 @@ proc extractSlice(js: JsonNode): Slice[int] = result = js["indices"][0].getInt ..< js["indices"][1].getInt proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode; - textLen: int; hideTwitter = false) = + textLen: int; hideTwitter = false; + hideArticle = false) = let url = js.getExpandedUrl slice = js.extractSlice - if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: + if hideArticle and url.isTwitterUrl and "/article/" in url: + if slice.a < textLen: + result.add ReplaceSlice(kind: rkRemove, slice: slice) + elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: if slice.a < textLen: result.add ReplaceSlice(kind: rkRemove, slice: slice) else: @@ -343,7 +347,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = hasQuote or hasJobCard) proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; - hasRedundantLink=false) = + hasRedundantLink=false; hasArticle=false) = let hasCard = tweet.card.isSome var replacements = newSeq[ReplaceSlice]() @@ -354,7 +358,8 @@ proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: S if urlStr.len == 0 or urlStr notin text: continue - replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) + replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink, + hideArticle = hasArticle) if hasCard and u{"url"}.getStr == get(tweet.card).url: get(tweet.card).url = u.getExpandedUrl @@ -385,7 +390,7 @@ proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: S tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) -proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) = +proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) = let textRange = js{"details", "display_text_range"} textSlice = textRange{0}.getInt .. textRange{1}.getInt @@ -394,7 +399,8 @@ proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) = hasAttribution = tweet.attribution.isSome tweet.expandTextEntitiesV2(js, tweet.text, textSlice, - hasQuote or hasJobCard or hasAttribution) + hasQuote or hasJobCard or hasAttribution, + hasArticle) proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let diff --git a/src/routes/article.nim b/src/routes/article.nim new file mode 100644 index 0000000..0a7d1dc --- /dev/null +++ b/src/routes/article.nim @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import asyncdispatch, tables, strutils +import jester, karax/vdom +import ".."/[types, api] +import ../views/[article, general] +import router_utils + +export api, article, vdom, general, router_utils + +proc createArticleRouter*(cfg: Config) = + router articleRoute: + get "/i/article/@id": + cond @"id".allCharsInSet(Digits) + + let article = await getGraphArticle(@"id") + if article == nil: + resp Http404, showError("Article not found", cfg) + + var tweetIds: seq[string] + for e in article.entities.values: + if e.kind == "TWEET": + tweetIds.add e.tweetId + + var tweets = initTable[int64, Tweet]() + if tweetIds.len > 0: + try: + for t in await getGraphTweetResults(tweetIds): + tweets[t.id] = t + except CatchableError: + discard + + let + prefs = requestPrefs() + path = getPath() + html = renderArticle(article, tweets, path, prefs, @"id") + twitterUrl = "https://x.com/" & article.user.username & "/article/" & @"id" + resp renderMain(html, request, cfg, prefs, titleText=article.title, + twitterLink=twitterUrl) + + get "/@name/article/@id/?": + cond '.' notin @"name" + cond @"id".allCharsInSet(Digits) + redirect("/i/article/" & @"id") + + get "/@name/status/@id/article": + cond '.' notin @"name" + cond @"id".allCharsInSet(Digits) + redirect("/i/article/" & @"id") diff --git a/src/sass/_article.scss b/src/sass/_article.scss new file mode 100644 index 0000000..9cc728a --- /dev/null +++ b/src/sass/_article.scss @@ -0,0 +1,272 @@ +.article-page { + max-width: 700px; + margin: 0 auto 20px; + background-color: var(--bg_panel); + + > .top-ref { + padding-top: 20px; + } + + .article-cover { + width: 100%; + display: block; + } + + .article-body { + padding: 20px; + + > :last-child { + margin-bottom: 0; + } + + .article-title { + display: block; + font-size: 2rem; + line-height: 1.3; + margin: 0 0 10px; + color: var(--fg_color); + } + + .article-author { + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border_grey); + font-size: 14px; + + .article-author-row { + display: flex; + align-items: center; + gap: 8px; + } + + .article-avatar { + display: flex; + } + + .avatar { + width: 40px; + height: 40px; + } + + .article-author-name { + display: flex; + align-items: center; + margin-bottom: 2px; + + .fullname { + font-size: 15px; + } + + .verified-icon { + margin-left: 2px; + } + } + + .article-author-meta { + display: flex; + align-items: center; + } + + .fullname { + font-weight: 700; + color: var(--fg_color); + max-width: unset; + text-overflow: unset; + overflow: visible; + white-space: normal; + } + + .username, + .article-date-sep, + .article-date { + color: var(--fg_dark); + } + + .username { + margin-left: 0; + } + + .article-date-sep { + margin: 0 4px; + } + + .article-date { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .tweet-stats { + margin-top: 6px; + + .tweet-stat { + padding-top: 0; + } + } + } + + > h1 { + display: block; + font-size: 1.8rem; + margin: 25px 0 15px; + } + + > h2 { + font-size: 1.4rem; + font-weight: bold; + margin: 20px 0 12px; + } + + > h3 { + font-size: 1.2rem; + font-weight: bold; + margin: 18px 0 10px; + } + + > p { + font-size: 16px; + line-height: 1.7; + margin: 16px 0; + word-wrap: break-word; + } + + .blockquote-attribution { + display: block; + margin-top: 0.5em; + } + + > blockquote { + border-left: 3px solid var(--accent); + padding-left: 16px; + margin: 16px 0; + color: var(--fg_faded); + font-size: 16px; + line-height: 1.7; + } + + > pre { + background-color: var(--bg_elements); + padding: 12px 16px; + border-radius: 6px; + overflow-x: auto; + margin: 16px 0; + + code { + font-family: monospace; + font-size: 14px; + color: var(--fg_color); + } + } + + code { + background-color: var(--bg_elements); + padding: 2px 5px; + border-radius: 3px; + font-family: monospace; + font-size: 0.9em; + } + + > ul, + > ol { + margin: 16px 0; + padding-left: 2em; + + li { + font-size: 16px; + line-height: 1.7; + margin: 6px 0; + } + } + + .article-media { + text-align: center; + margin: 20px 0; + + img, + video { + max-width: 100%; + border-radius: 12px; + } + } + + > a, + > p a, + > h1 a, + > h2 a, + > h3 a, + > blockquote a, + > ul a, + > ol a { + color: var(--accent); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .article-divider { + border: none; + border-top: 1px solid var(--border_grey); + margin: 30px 0; + } + + .timeline-item { + margin: 20px 0; + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + } + } +} + +.conversation .article-page { + max-width: 100%; + margin-bottom: 0; +} + +.article-card { + .card-image-container { + position: relative; + } + + .card-image img { + height: auto; + } + + .article-card-badge { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + font-size: 13px; + font-weight: 700; + padding: 2px 8px; + border-radius: 4px; + } +} + +.quote .article-card { + margin: 0; + + .card-container { + border: none; + border-radius: 0; + border-top: solid 1px var(--dark_grey); + } +} + +@media (max-width: 700px) { + .article-page { + .article-body { + padding: 12px 15px 25px; + + .article-title { + font-size: 1.6rem; + } + } + } +} diff --git a/src/sass/index.scss b/src/sass/index.scss index e60b9c4..8126fe9 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -8,6 +8,7 @@ @import "timeline"; @import "search"; @import "broadcast"; +@import "_article"; body { // colors diff --git a/src/types.nim b/src/types.nim index 3604efb..05b7fb3 100644 --- a/src/types.nim +++ b/src/types.nim @@ -198,6 +198,43 @@ type PhotoRail* = seq[GalleryPhoto] + Article* = ref object + title*: string + coverImage*: string + user*: User + time*: DateTime + stats*: TweetStats + paragraphs*: seq[ArticleParagraph] + entities*: Table[int, ArticleEntity] + media*: Table[string, ArticleMedia] + + ArticleParagraph* = object + text*: string + kind*: string + inlineStyles*: seq[ArticleStyle] + entityRanges*: seq[ArticleEntityRange] + + ArticleStyle* = object + offset*: int + length*: int + style*: string + + ArticleEntityRange* = object + offset*: int + length*: int + key*: int + + ArticleEntity* = object + kind*: string + url*: string + mediaIds*: seq[string] + tweetId*: string + markdown*: string + + ArticleMedia* = object + kind*: string + url*: string + Poll* = object options*: seq[string] values*: seq[int] @@ -247,6 +284,12 @@ type likes*: int views*: int + ArticlePreview* = object + title*: string + previewText*: string + coverImage*: string + tweetId*: int64 + Tweet* = ref object id*: int64 threadId*: int64 @@ -275,6 +318,7 @@ type note*: string isAd*: bool isAI*: bool + articlePreview*: Option[ArticlePreview] Tweets* = seq[Tweet] diff --git a/src/views/article.nim b/src/views/article.nim new file mode 100644 index 0000000..1ba9c8b --- /dev/null +++ b/src/views/article.nim @@ -0,0 +1,246 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, strformat, tables, unicode, bitops, uri +import karax/[karaxdsl, vdom] + +import renderutils, tweet, timeline +import ".."/[types, utils, formatters] + +proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article; + tweets: Table[int64, Tweet]; path: string; + prefs: Prefs): VNode = + if paragraph.entityRanges.len == 0: + return text "" + + let er = paragraph.entityRanges[0] + if er.key notin article.entities: + return text "" + + let entity = article.entities[er.key] + + case entity.kind + of "MEDIA": + buildHtml(tdiv(class="article-media")): + for id in entity.mediaIds: + let media = article.media.getOrDefault(id) + if media.url.len == 0: + continue + case media.kind + of "ApiGif": + video(src=getVidUrl(media.url), controls="", autoplay="", loop="", + muted="") + of "ApiVideo": + video(src=getVidUrl(media.url), controls="") + else: + a(href=getOrigPicUrl(media.url), target="_blank"): + img(src=getSmallPic(media.url), alt="", loading="lazy") + of "TWEET": + let tweet = tweets.getOrDefault( + try: parseBiggestInt(entity.tweetId) + except ValueError: 0, nil) + if tweet != nil: + renderTweet(tweet, prefs, path) + else: + text "" + of "MARKDOWN": + var content = entity.markdown + if content.startsWith("```"): + let firstNl = content.find('\n') + if firstNl >= 0: content = content[firstNl + 1 .. ^1] + if content.endsWith("```"): content = content[0 .. ^4] + content = content.strip + buildHtml(pre()): + code(): text content + of "DIVIDER": + buildHtml(hr(class="article-divider")) + else: + text "" + +proc wrapStyle(node: VNode; style: int): VNode = + result = node + if style.testBit(4): result = buildHtml(code()): result + if style.testBit(0): result = buildHtml(strong()): result + if style.testBit(1): result = buildHtml(em()): result + if style.testBit(2): result = buildHtml(del()): result + if style.testBit(3): result = buildHtml(underlined()): result + +proc addContent(target: VNode; content: string; style = 0) = + var first = true + for line in content.split('\n'): + if not first: + target.add VNode(kind: VNodeKind.br) + first = false + var pos = 0 + while pos < line.len: + let atPos = line.find('@', pos) + if atPos == -1: + target.add wrapStyle(text line[pos .. ^1], style) + break + if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}: + target.add wrapStyle(text line[pos .. atPos], style) + pos = atPos + 1 + continue + var j = atPos + 1 + while j < line.len and j - atPos - 1 < 15 and + line[j] in Letters + Digits + {'_'}: + inc j + if j == atPos + 1: + target.add wrapStyle(text line[pos .. atPos], style) + pos = atPos + 1 + continue + if atPos > pos: + target.add wrapStyle(text line[pos ..< atPos], style) + let username = line[atPos + 1 ..< j] + let link = a.newVNode() + link.setAttr("href", "/" & username) + link.add wrapStyle(text ("@" & username), style) + target.add link + pos = j + +proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int; + styles: seq[ArticleStyle]) = + if styles.len == 0: + target.addContent($runes[start ..< start + length]) + return + + var + lastStyle = 0 + lastStart = start + let endPos = start + length + + for i in start ..< endPos: + var style = 0 + for sr in styles: + let + sStart = sr.offset + sEnd = sStart + sr.length + if sStart <= i and sEnd > i: + case sr.style + of "Bold": style.setBit(0) + of "Italic": style.setBit(1) + of "Strikethrough": style.setBit(2) + of "Underline": style.setBit(3) + of "Code": style.setBit(4) + else: discard + + if style != lastStyle: + if i > lastStart: + addContent(target, $runes[lastStart ..< i], lastStyle) + lastStyle = style + lastStart = i + + if lastStart < endPos: + addContent(target, $runes[lastStart ..< endPos], lastStyle) + +proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode = + let text = paragraph.text + + result = case paragraph.kind + of "header-one": h1.newVNode() + of "header-two": h2.newVNode() + of "header-three": h3.newVNode() + of "ordered-list-item", "unordered-list-item": li.newVNode() + of "blockquote": VNode(kind: VNodeKind.blockquote) + of "code-block": + let pre = pre.newVNode() + let code = code.newVNode() + code.add text text + pre.add code + return pre + else: p.newVNode() + + let + runes = text.toRunes + textLen = runes.len + var last = 0 + for er in paragraph.entityRanges: + if er.offset > last: + applyInlineStyles(result, runes, last, er.offset - last, + paragraph.inlineStyles) + + last = er.offset + er.length + + var target = result + if er.key in article.entities: + let entity = article.entities[er.key] + if entity.kind == "LINK": + let parsed = parseUri(entity.url) + if parsed.scheme in ["http", "https"]: + target = a.newVNode() + if parsed.isTwitterUrl: + target.setAttr("href", parsed.path) + else: + target.setAttr("href", entity.url) + + applyInlineStyles(target, runes, er.offset, er.length, + paragraph.inlineStyles) + if target != result: + result.add target + + if last < textLen: + applyInlineStyles(result, runes, last, textLen - last, + paragraph.inlineStyles) + + if paragraph.kind == "blockquote" and result.len > 0: + let lastChild = result[result.len - 1] + if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and + lastChild[0].kind == VNodeKind.text: + lastChild.setAttr("class", "blockquote-attribution") + +proc renderArticle*(article: Article; tweets: Table[int64, Tweet]; + path: string; prefs: Prefs; tweetId=""): VNode = + let author = article.user + + let main = buildHtml(article(class="article-body")): + h1(class="article-title"): text article.title + + tdiv(class="article-author"): + tdiv(class="article-author-row"): + a(class="article-avatar", href=("/" & author.username)): + genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass) + tdiv(class="article-author-info"): + tdiv(class="article-author-name"): + linkUser(author, class="fullname") + verifiedIcon(author) + tdiv(class="article-author-meta"): + linkUser(author, class="username") + span(class="article-date-sep"): text " · " + a(class="article-date", + href=("/" & author.username & "/status/" & tweetId)): + text article.time.getShortTime + if not prefs.hideTweetStats: + renderStats(article.stats) + + var listKind = "" + var list: VNode = nil + + for paragraph in article.paragraphs: + let isListItem = paragraph.kind in [ + "ordered-list-item", "unordered-list-item"] + + if not isListItem and list != nil: + main.add list + list = nil + listKind = "" + + if paragraph.kind == "atomic": + main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs) + elif isListItem: + if paragraph.kind != listKind: + if list != nil: + main.add list + list = if paragraph.kind == "ordered-list-item": ol.newVNode() + else: ul.newVNode() + listKind = paragraph.kind + list.add renderTextParagraph(paragraph, article) + else: + main.add renderTextParagraph(paragraph, article) + + if list != nil: + main.add list + + buildHtml(tdiv(class="article-page")): + if article.coverImage.len > 0: + a(href=getOrigPicUrl(article.coverImage), target="_blank"): + img(class="article-cover", src=getSmallPic(article.coverImage), alt="") + main + renderToTop() diff --git a/src/views/general.nim b/src/views/general.nim index f753610..13ac10d 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=35") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=38") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") if theme.len > 0: @@ -122,9 +122,12 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; - images: seq[string] = @[]; banner=""): string = + images: seq[string] = @[]; banner=""; + twitterLink=""): string = - let twitterLink = getTwitterLink(req.path, req.params) + let twitterLink = + if twitterLink.len > 0: twitterLink + else: getTwitterLink(req.path, req.params) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 5546eb4..9bf6c9b 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -9,9 +9,24 @@ import general const doctype = "\n" -proc renderMiniAvatar(user: User; prefs: Prefs): VNode = +proc renderMiniAvatar*(user: User; prefs: Prefs): VNode = genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini")) +proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode = + let url = "/i/article/" & $preview.tweetId + buildHtml(tdiv(class="article-card card large")): + a(class="card-container", href=url): + if preview.coverImage.len > 0: + tdiv(class="card-image-container"): + tdiv(class="card-image"): + genImg(preview.coverImage) + span(class="article-card-badge"): text "Article" + tdiv(class="card-content-container"): + tdiv(class="card-content"): + h2(class="card-title"): text preview.title + if preview.previewText.len > 0: + p(class="card-description"): text preview.previewText + proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode = buildHtml(tdiv): if pinned: @@ -225,7 +240,7 @@ func formatStat(stat: int): string = if stat > 0: insertSep($stat, ',') else: "" -proc renderStats(stats: TweetStats): VNode = +proc renderStats*(stats: TweetStats): VNode = buildHtml(tdiv(class="tweet-stats")): span(class="tweet-stat"): icon "comment", formatStat(stats.replies) span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) @@ -308,6 +323,9 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if quote.media.len > 0: renderQuoteMedia(quote, prefs, path) + if quote.articlePreview.isSome: + renderArticleCard(quote.articlePreview.get(), prefs) + if quote.note.len > 0 and not prefs.hideCommunityNotes: renderCommunityNote(quote.note, prefs) @@ -392,6 +410,9 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.card.isSome and tweet.card.get().kind != hidden: renderCard(tweet.card.get(), prefs, path) + if tweet.articlePreview.isSome: + renderArticleCard(tweet.articlePreview.get(), prefs) + if tweet.media.len > 0: renderMedia(tweet.media, prefs, path, bigThumb) diff --git a/tests/test_article.py b/tests/test_article.py new file mode 100644 index 0000000..15a87c7 --- /dev/null +++ b/tests/test_article.py @@ -0,0 +1,307 @@ +from base import BaseTestCase +from parameterized import parameterized + + +class ArticleSelectors: + page = '.article-page' + cover = '.article-cover' + body = '.article-body' + title = '.article-title' + author = '.article-author' + fullname = '.article-author .fullname' + username = '.article-author .username' + date = '.article-author .article-date' + avatar = '.article-author img.avatar' + verified = '.article-author .verified-icon' + media = '.article-media' + divider = '.article-divider' + + +articles = [ + ['2064166507438059759', + '1s,秒杀一切,开源一个 X 文章发布 Skill【重磅升级】', + 'punk2898', 'Punk'], + + ['2064689664213041529', + 'SpaceX Thesis & Valuation Memorandum', + 'Dialectic_Group', 'Dialectic'], + + ['2064691088636424322', + 'Consciousness and AI: The Problem of Inner Experience', + 'CosmicOrFun', 'Cosmic Orphan'], + + ['2064696491948777658', + 'NC Push for Data Centers + Stablecoin Crypto= Data Centers are defacto BAILOUT OF Fed Reserve System', + 'June_12_1776', 'June_12_1776'], + + ['2064755789391110154', + 'DeFi Markets Update 2026-06-10', + 'SteakhouseFi', 'Steakhouse Financial'], + + ['2064755231901319527', + 'The machine economy has a killswitch and somebody just pulled it.', + '1914ad', 'Justin Bechler HMP-028'], + + ['2062858677149675788', + 'Yakshinis', + 'CosmicOrFun', 'Cosmic Orphan'], +] + +articles_with_media = [ + ['2064166507438059759', 6], + ['2064689664213041529', 11], + ['2064755789391110154', 5], +] + +articles_with_dividers = [ + ['2064166507438059759', 1], + ['2064689664213041529', 6], +] + + +class ArticleBasicTest(BaseTestCase): + @parameterized.expand(articles) + def test_article_loads(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.page) + self.assert_element_visible(ArticleSelectors.body) + self.assert_text(title, ArticleSelectors.title) + + @parameterized.expand(articles) + def test_article_author(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.author) + self.assert_text(f'@{username}', ArticleSelectors.username) + + @parameterized.expand(articles) + def test_article_has_cover(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.cover) + src = self.get_attribute(ArticleSelectors.cover, 'src') + self.assertIn('/pic/', src) + + @parameterized.expand(articles) + def test_article_has_date(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + date_text = self.get_text(ArticleSelectors.date) + self.assertTrue(len(date_text) > 3) + + @parameterized.expand(articles) + def test_article_author_avatar(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.avatar) + src = self.get_attribute(ArticleSelectors.avatar, 'src') + self.assertIn('/pic/', src) + self.assertGreater(len(src), len('/pic/')) + + @parameterized.expand(articles) + def test_article_author_verified(self, tweet_id, title, username, fullname): + self.open_nitter(f'i/article/{tweet_id}') + self.assert_element_visible(ArticleSelectors.verified) + + def test_article_author_verified_business(self): + self.open_nitter('i/article/2064755789391110154') + self.assert_element_visible('.article-author .verified-icon.business') + + +class ArticleContentTest(BaseTestCase): + def test_article_has_paragraphs(self): + self.open_nitter('i/article/2064689664213041529') + paragraphs = self.find_elements('.article-body p') + self.assertGreater(len(paragraphs), 10) + + def test_article_has_headers(self): + self.open_nitter('i/article/2064689664213041529') + headers = self.find_elements('.article-body h1, .article-body h2') + self.assertGreater(len(headers), 5) + + def test_article_has_bold_text(self): + self.open_nitter('i/article/2064166507438059759') + bold = self.find_elements('.article-body strong') + self.assertGreater(len(bold), 0) + + def test_article_has_italic_text(self): + self.open_nitter('i/article/2064166507438059759') + italic = self.find_elements('.article-body em') + self.assertGreater(len(italic), 0) + + def test_article_has_blockquotes(self): + self.open_nitter('i/article/2064696491948777658') + self.assert_element_visible('.article-body blockquote') + + def test_article_has_lists(self): + self.open_nitter('i/article/2064696491948777658') + self.assert_element_visible('.article-body ul') + + def test_article_has_emoji_text(self): + self.open_nitter('i/article/2064166507438059759') + body = self.get_text(ArticleSelectors.body) + self.assertTrue(any(ord(c) > 0x1F000 for c in body)) + + def test_article_has_links(self): + self.open_nitter('i/article/2064691088636424322') + links = self.find_elements('.article-body a[href]') + self.assertGreater(len(links), 0) + + def test_article_twitter_links_localized(self): + self.open_nitter('i/article/2064755789391110154') + links = self.find_elements('.article-body a[href^="https://x.com"]') + self.assertEqual(len(links), 0, 'x.com links should be converted to local paths') + + @parameterized.expand(articles_with_media) + def test_article_media_count(self, tweet_id, expected_count): + self.open_nitter(f'i/article/{tweet_id}') + media = self.find_elements(ArticleSelectors.media) + self.assertEqual(len(media), expected_count) + + @parameterized.expand(articles_with_dividers) + def test_article_divider_count(self, tweet_id, expected_count): + self.open_nitter(f'i/article/{tweet_id}') + dividers = self.find_elements(ArticleSelectors.divider) + self.assertEqual(len(dividers), expected_count) + + +class ArticleMediaTest(BaseTestCase): + def test_media_images_proxied(self): + self.open_nitter('i/article/2064689664213041529') + self.assert_element_visible(ArticleSelectors.media) + img = self.find_element(f'{ArticleSelectors.media} img') + src = img.get_attribute('src') + self.assertIn('/pic/', src) + self.assertFalse(src.startswith('https://pbs.twimg.com')) + + def test_cover_image_proxied(self): + self.open_nitter('i/article/2064689664213041529') + self.assert_element_visible(ArticleSelectors.cover) + src = self.get_attribute(ArticleSelectors.cover, 'src') + self.assertIn('/pic/', src) + self.assertFalse(src.startswith('https://pbs.twimg.com')) + + def test_embedded_tweet(self): + self.open_nitter('i/article/2064755789391110154') + self.assert_element_visible('.article-body .timeline-item') + + def test_multiple_embedded_tweets(self): + self.open_nitter('i/article/2064755231901319527') + tweets = self.find_elements('.article-body .timeline-item') + self.assertGreaterEqual(len(tweets), 3) + + +class ArticleMentionTest(BaseTestCase): + def test_mention_linkified(self): + self.open_nitter('i/article/2064755231901319527') + link = self.find_element('.article-body a[href="/ZachXBT"]') + self.assertEqual(link.text, '@ZachXBT') + + def test_multiple_mentions_linkified(self): + self.open_nitter('i/article/2064755231901319527') + links = self.find_elements('.article-body a[href^="/"]') + mention_hrefs = [l.get_attribute('href') for l in links + if l.text.startswith('@')] + usernames = [h.split('/')[-1] for h in mention_hrefs] + self.assertIn('ZachXBT', usernames) + self.assertIn('River', usernames) + + def test_mention_in_different_article(self): + self.open_nitter('i/article/2064689664213041529') + link = self.find_element('.article-body a[href="/FutureJurvetson"]') + self.assertEqual(link.text, '@FutureJurvetson') + + def test_no_spurious_whitespace_in_styled_paragraph(self): + """Styled paragraphs should not have extra whitespace from VNode serialization.""" + self.open_nitter('i/article/2064696491948777658') + source = self.get_page_source() + self.assertNotIn('white-space: pre-wrap', source) + self.assertNotIn('white-space:pre-wrap', source) + + +class ArticleCardTest(BaseTestCase): + @parameterized.expand(articles) + def test_status_page_shows_article_card(self, tweet_id, title, username, fullname): + self.open_nitter(f'{username}/status/{tweet_id}') + self.assert_element_visible('.article-card') + self.assert_text(title, '.article-card .card-title') + + def test_article_card_has_cover_image(self): + self.open_nitter('Dialectic_Group/status/2064689664213041529') + self.assert_element_visible('.article-card .card-image img') + src = self.get_attribute('.article-card .card-image img', 'src') + self.assertIn('/pic/', src) + + def test_article_card_has_badge(self): + self.open_nitter('Dialectic_Group/status/2064689664213041529') + self.assert_element_visible('.article-card-badge') + self.assert_text('Article', '.article-card-badge') + + def test_article_card_has_preview_text(self): + self.open_nitter('CosmicOrFun/status/2064691088636424322') + self.assert_element_visible('.article-card .card-description') + + def test_article_card_links_to_article(self): + self.open_nitter('punk2898/status/2064166507438059759') + href = self.get_attribute('.article-card .card-container', 'href') + self.assertIn('/article/', href) + + def test_article_url_stripped_from_tweet_text(self): + self.open_nitter('punk2898/status/2064166507438059759') + self.assert_element_visible('.article-card') + source = self.get_page_source() + # Main tweet text should not contain article URL + import re + main = re.search(r'id="m".*?tweet-content[^>]*>(.*?)
', source, re.DOTALL) + self.assertIsNotNone(main) + self.assertNotIn('/article/', main.group(1)) + + +class ArticleQuotedCardTest(BaseTestCase): + """Article cards inside quoted tweets (1914ad quoting own article).""" + quoted_tweet = '1914ad/status/2064789532071891085' + quoted_article_id = '2063677483548102688' + + def test_quoted_card_visible(self): + self.open_nitter(self.quoted_tweet) + self.assert_element_visible('.quote .article-card') + + def test_quoted_card_has_title(self): + self.open_nitter(self.quoted_tweet) + self.assert_text('David Bailey Already Won', '.quote .article-card .card-title') + + def test_quoted_card_has_badge(self): + self.open_nitter(self.quoted_tweet) + self.assert_element_visible('.quote .article-card-badge') + self.assert_text('Article', '.quote .article-card-badge') + + def test_quoted_card_has_cover_image(self): + self.open_nitter(self.quoted_tweet) + self.assert_element_visible('.quote .article-card .card-image img') + src = self.get_attribute('.quote .article-card .card-image img', 'src') + self.assertIn('/pic/', src) + + def test_quoted_card_has_description(self): + self.open_nitter(self.quoted_tweet) + self.assert_element_visible('.quote .article-card .card-description') + + def test_quoted_card_links_to_article(self): + self.open_nitter(self.quoted_tweet) + href = self.get_attribute('.quote .article-card .card-container', 'href') + self.assertIn(f'/article/{self.quoted_article_id}', href) + + +class ArticleRoutingTest(BaseTestCase): + def test_username_article_route_redirects(self): + self.open_nitter('punk2898/article/2064166507438059759') + self.assert_element_visible(ArticleSelectors.page) + self.assert_text('1s', ArticleSelectors.title) + + def test_status_article_route_redirects(self): + self.open_nitter('punk2898/status/2064166507438059759/article') + self.assert_element_visible(ArticleSelectors.page) + self.assert_text('1s', ArticleSelectors.title) + + def test_invalid_id_returns_404(self): + self.open_nitter('i/article/notanumber') + self.assert_element_not_visible(ArticleSelectors.page) + + def test_nonexistent_article(self): + self.open_nitter('i/article/1') + self.assert_element_visible('.error-panel') From 7b27c2c629a55661ad5b4ad91fb1a7b2d3f9910e Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 18 Jun 2026 13:32:20 +0200 Subject: [PATCH 243/309] Add support for Communities Fixes #1270 --- src/api.nim | 51 ++++++++ src/consts.nim | 37 ++++++ src/nitter.nim | 4 +- src/parser.nim | 76 +++++++++++ src/redis_cache.nim | 23 ++++ src/routes/community.nim | 89 +++++++++++++ src/sass/index.scss | 1 + src/sass/profile/_base.scss | 1 + src/sass/profile/_community.scss | 203 +++++++++++++++++++++++++++++ src/types.nim | 17 +++ src/views/community.nim | 128 +++++++++++++++++++ src/views/general.nim | 2 +- src/views/renderutils.nim | 6 +- src/views/timeline.nim | 3 +- src/views/tweet.nim | 15 ++- temp | 0 tests/test_community.py | 211 +++++++++++++++++++++++++++++++ 17 files changed, 856 insertions(+), 11 deletions(-) create mode 100644 src/routes/community.nim create mode 100644 src/sass/profile/_community.scss create mode 100644 src/views/community.nim delete mode 100644 temp create mode 100644 tests/test_community.py diff --git a/src/api.nim b/src/api.nim index 2702f7c..9aa6c50 100644 --- a/src/api.nim +++ b/src/api.nim @@ -94,6 +94,57 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi js = await fetch(url) result = parseGraphTimeline(js, after) +proc getGraphCommunity*(id: string): Future[Community] {.async.} = + if id.len == 0: return + let + url = apiReq(graphCommunity, communityVars % id) + js = await fetch(url) + result = parseGraphCommunity(js) + +proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future[Timeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + url = apiReq(graphCommunityTweets, communityTweetsVars % [id, cursor, rankingMode]) + js = await fetch(url) + result = parseGraphCommunityTimeline(js, after) + +proc getGraphCommunityMedia*(id: string; after=""): Future[Timeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + url = apiReq(graphCommunityMedia, communityMediaVars % [id, cursor]) + js = await fetch(url) + result = parseGraphCommunityTimeline(js, after) + +proc communitySliceReq(endpoint, variables: string): ApiReq = + let url = ApiUrl(endpoint: endpoint, params: @[("variables", variables)]) + ApiReq(cookie: url, oauth: url) + +proc getGraphCommunityMembers*(id: string; after=""): Future[Result[User]] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"$1\"" % after else: "null" + url = communitySliceReq(graphCommunityMembers, communityMembersVars % [id, cursor]) + js = await fetch(url) + result = parseGraphCommunityMembers(js, after) + +proc getGraphCommunityModerators*(id: string): Future[Result[User]] {.async.} = + if id.len == 0: return + let + url = communitySliceReq(graphCommunityModerators, communityMembersVars % [id, "null"]) + js = await fetch(url) + result = parseGraphCommunityMembers(js) + +proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] {.async.} = + if id.len == 0 or hashtag.len == 0: return + let + safeTag = multiReplace(hashtag, ("\"", ""), ("\\", "")) + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + url = apiReq(graphCommunityHashtags, communityHashtagsVars % [id, cursor, safeTag]) + js = await fetch(url) + result = parseGraphCommunityTimeline(js, after) + proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let diff --git a/src/consts.nim b/src/consts.nim index 5ac3c32..59b986c 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -28,6 +28,13 @@ const graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline" graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery" + graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery" + graphCommunityTweets* = "Mvs5UOOEkpXVMDZtUcxR-Q/CommunityTweetsTimeline" + graphCommunityMedia* = "Bt9XYnY7D3OcmZE5lhdx-A/CommunityMediaTimeline" + graphCommunityMembers* = "WSbJGJjZaVasSj9bnqSZSA/membersSliceTimeline_Query" + graphCommunityModerators* = "GBMT3GOWy5dYsYC4XJfvow/moderatorsSliceTimeline_Query" + graphCommunityHashtags* = "40DyrMxfCknGuZwE-keW_Q/CommunityHashtagsTimeline" + graphTweetResultByRestId* = "qtXMy1p5Y62uCskc_NUPJw/TweetResultByRestId" graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds" @@ -47,6 +54,8 @@ const "premium_content_api_read_enabled": false, "communities_web_enable_tweet_community_results_fetch": true, "c9s_tweet_anatomy_moderator_badge_enabled": true, + "c9s_list_members_action_api_enabled": false, + "c9s_superc9s_indication_enabled": false, "responsive_web_grok_analyze_button_fetch_trends_enabled": false, "responsive_web_grok_analyze_post_followups_enabled": true, "rweb_cashtags_composer_attachment_enabled": true, @@ -152,6 +161,34 @@ const articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}""" + communityVars* = """{"communityId":"$1"}""" + + communityTweetsVars* = """{ + "communityId": "$1", $2 + "count": 20, + "displayLocation": "Community", + "rankingMode": "$3", + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + communityMediaVars* = """{ + "communityId": "$1", $2 + "count": 20, + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + + communityMembersVars* = """{ + "communityId": "$1", + "cursor": $2 +}""".replace(" ", "").replace("\n", "") + + communityHashtagsVars* = """{ + "communityId": "$1", $2 + "count": 20, + "hashtags": ["$3"], + "withCommunity": true +}""".replace(" ", "").replace("\n", "") + userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}""" userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" diff --git a/src/nitter.nim b/src/nitter.nim index 78cace4..955849b 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -9,7 +9,7 @@ import jester import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils import views/[general, about] import routes/[ - preferences, timeline, status, media, search, rss, list, debug, + preferences, timeline, status, media, search, rss, list, community, debug, unsupported, embed, resolver, broadcast, article, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" @@ -54,6 +54,7 @@ createResolverRouter(cfg) createPrefRouter(cfg) createTimelineRouter(cfg) createListRouter(cfg) +createCommunityRouter(cfg) createStatusRouter(cfg) createSearchRouter(cfg) createMediaRouter(cfg) @@ -126,6 +127,7 @@ routes: extend timeline, "" extend media, "" extend list, "" + extend community, "" extend preferences, "" extend resolver, "" extend embed, "" diff --git a/src/parser.nim b/src/parser.nim index d7b3f8d..c810f76 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -131,6 +131,36 @@ proc parseBroadcastInfo*(js: JsonNode): Broadcast = user: parseGraphUser(bc) ) +proc parseGraphCommunity*(js: JsonNode): Community = + if js.isNull: return + let c = ? js{"data", "communityResults", "result"} + + result = Community( + id: c{"rest_id"}.getStr(c{"id_str"}.getStr), + name: c{"name"}.getStr, + description: c{"description"}.getStr, + memberCount: c{"member_count"}.getInt, + joinPolicy: c{"join_policy"}.getStr, + category: c{"primary_community_topic", "topic_name"}.getStr, + banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr, + creator: parseGraphUser(c{"creator_results", "result"}), + ) + + let createdMs = c{"created_at"}.getInt(0) + if createdMs > 0: + result.createdAt = fromUnix(createdMs div 1000).utc() + + for rule in c{"rules"}: + result.rules.add CommunityRule( + name: rule{"name"}.getStr, + description: rule{"description"}.getStr + ) + + for item in c{"trending_hashtags_slice", "items"}: + let tag = item{"hashtag"}.getStr + if tag.len > 0: + result.hashtags.add tag + proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -855,3 +885,49 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): result.bottom = instruction{"entry", "content", "value"}.getStr + +proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline = + result = Timeline(beginning: after.len == 0) + + let communityResult = js{"data", "communityResults", "result"} + let instructions = ? select( + communityResult{"ranked_community_timeline", "timeline", "instructions"}, + communityResult{"community_media_timeline", "timeline", "instructions"}, + communityResult{"community_filtered_timeline", "timeline", "instructions"} + ) + if instructions.len == 0: + return + + for i in instructions: + if i{"entries"}.notNull: + for e in i{"entries"}: + let entryId = e.getEntryId + if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or + entryId.startsWith("communities-grid"): + for tweet in extractTweetsFromEntry(e): + result.content.add tweet + elif entryId.startsWith("cursor-bottom"): + result.bottom = e{"content", "value"}.getStr + + if after.len == 0 and i.getTypeName == "TimelinePinEntry": + var tweets = extractTweetsFromEntry(i{"entry"}) + for tweet in tweets.mitems: + tweet.pinned = true + if tweets.len > 0: + result.content.insert(tweets, 0) + +proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] = + result = Result[User](beginning: after.len == 0) + + let r = js{"data", "communityResults", "result"} + let slice = if not r{"members_slice"}.isNull: r{"members_slice"} + else: r{"moderators_slice"} + for item in slice{"items_results"}: + let user = parseGraphUser(item{"result"}) + if user.username.len > 0: + result.content.add user + + let cursor = slice{"slice_info", "next_cursor"}.getStr + if cursor.len > 0: + result.bottom = cursor + diff --git a/src/redis_cache.nim b/src/redis_cache.nim index e503e46..4d5bf28 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -195,6 +195,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = result = await getPhotoRail(id) await cache(result, id) +proc cache*(data: Community) {.async.} = + if data.id.len == 0: return + await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data))) + +proc getCachedCommunity*(id: string): Future[Community] {.async.} = + if id.len == 0: return + let cached = await get("cm:" & id) + if cached != redisNil: + cached.deserialize(Community) + else: + result = await getGraphCommunity(id) + await cache(result) + +proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} = + if id.len == 0: return + let cached = await get("cmm:" & id) + if cached != redisNil: + cached.deserialize(seq[User]) + else: + let mods = await getGraphCommunityModerators(id) + result = mods.content + await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result))) + proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} = let list = if id.len == 0: redisNil else: await get("l:" & id) diff --git a/src/routes/community.nim b/src/routes/community.nim new file mode 100644 index 0000000..b850b6b --- /dev/null +++ b/src/routes/community.nim @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strformat + +import jester + +import router_utils +import ".."/[types, redis_cache, api] +import ../views/[general, timeline, community] + +export community + +template respCommunity*(cmty: Community; title: string; nav, vnode: typed) = + if cmty.id.len == 0 or cmty.name.len == 0: + resp Http404, showError(&"""Community "{@"id"}" not found""", cfg) + + let html = renderCommunity(vnode, nav, cmty) + resp renderMain(html, request, cfg, prefs, titleText=title, banner=cmty.banner) + +proc createCommunityRouter*(cfg: Config) = + router community: + get "/i/communities/@id/?": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + tl = await getGraphCommunityTweets(cmty.id, "Relevance", getCursor()) + respCommunity(cmty, cmty.name, + renderCommunityTabs(QueryKind.posts, cmty), + renderTimelineTweets(tl, prefs, request.path)) + + get "/i/communities/@id/latest": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + tl = await getGraphCommunityTweets(cmty.id, "Recency", getCursor()) + respCommunity(cmty, cmty.name & " - Latest", + renderCommunityTabs(QueryKind.replies, cmty), + renderTimelineTweets(tl, prefs, request.path)) + + get "/i/communities/@id/media": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + tl = await getGraphCommunityMedia(cmty.id, getCursor()) + respCommunity(cmty, cmty.name & " - Media", + renderCommunityTabs(QueryKind.media, cmty), + renderTimelineTweets(tl, prefs, request.path)) + + get "/i/communities/@id/about": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + mods = await getCachedCommunityModerators(cmty.id) + respCommunity(cmty, cmty.name & " - About", + renderCommunityTabs(QueryKind.userList, cmty), + renderCommunityAbout(cmty, mods)) + + get "/i/communities/@id/members": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + members = await getGraphCommunityMembers(cmty.id, getCursor()) + respCommunity(cmty, cmty.name & " - Members", + renderMemberTabs(cmty, false), + renderTimelineUsers(members, prefs, request.path)) + + get "/i/communities/@id/moderators": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + mods = await getCachedCommunityModerators(cmty.id) + respCommunity(cmty, cmty.name & " - Moderators", + renderMemberTabs(cmty, true), + renderTimelineUsers(Result[User](content: mods), prefs, request.path)) + + get "/i/communities/@id/hashtag/@tag": + cond '.' notin @"id" + let + prefs = requestPrefs() + cmty = await getCachedCommunity(@"id") + tl = await getGraphCommunityHashtags(cmty.id, @"tag", getCursor()) + respCommunity(cmty, cmty.name & " - #" & @"tag", + renderHashtagHeader(cmty, @"tag"), + renderTimelineTweets(tl, prefs, request.path)) diff --git a/src/sass/index.scss b/src/sass/index.scss index 8126fe9..b8c9a8c 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -160,6 +160,7 @@ body.fixed-nav .container { .verified-icon { display: inline-block; + position: relative; width: 14px; height: 14px; margin-bottom: 2px; diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index 2482460..81b3d78 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -4,6 +4,7 @@ @import "card"; @import "about-account"; @import "photo-rail"; +@import "community"; .profile-tabs { @include panel(auto, 900px); diff --git a/src/sass/profile/_community.scss b/src/sass/profile/_community.scss new file mode 100644 index 0000000..92d13c9 --- /dev/null +++ b/src/sass/profile/_community.scss @@ -0,0 +1,203 @@ +.community-header { + padding: 12px 15px; + border-bottom: 1px solid var(--border_grey); + background-color: var(--bg_panel); + + .community-name { + font-size: 22px; + margin-bottom: 6px; + + a { + color: inherit; + } + } + + .community-category { + display: inline-block; + background-color: var(--bg_elements); + border: 1px solid var(--border_grey); + border-radius: 16px; + padding: 2px 12px; + font-size: 13px; + color: var(--fg_faded); + margin-bottom: 8px; + } + + .community-description { + color: var(--fg_faded); + margin-bottom: 8px; + line-height: 1.4; + } + + .community-member-count { + font-weight: bold; + color: inherit; + } + + .community-stats { + color: var(--grey); + font-size: 14px; + } +} + +.community-about { + padding: 16px 15px 15px; + background-color: var(--bg_panel); + + h2 { + font-size: 18px; + margin: 0 0 12px; + } + + .community-info { + border-bottom: 1px solid var(--border_grey); + padding-bottom: 12px; + } + + .community-info-item { + display: flex; + gap: 10px; + padding: 8px 0; + align-items: center; + + .verified-icon { + margin-left: 2px; + } + + > .icon-container { + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--grey); + flex-shrink: 0; + width: 26px; + height: 26px; + } + + strong { + color: var(--fg_color); + } + + a { + color: var(--accent); + } + } + + .community-rules { + border-bottom: 1px solid var(--border_grey); + padding: 16px 0 12px; + } + + .community-rules-intro { + color: var(--fg_faded); + font-size: 14px; + margin: 0 0 12px; + } + + .community-rule { + display: flex; + gap: 10px; + padding: 10px 0; + align-items: flex-start; + + .community-rule-number { + display: flex; + align-items: center; + justify-content: center; + min-width: 26px; + height: 26px; + border-radius: 50%; + background-color: var(--accent); + color: var(--fg_color); + font-weight: bold; + font-size: 13px; + flex-shrink: 0; + } + + .community-rule-content p { + margin: 4px 0 0; + color: var(--fg_faded); + font-size: 14px; + } + } + + .community-moderators { + padding-top: 16px; + + h2 { + display: flex; + align-items: center; + justify-content: space-between; + } + + .community-mods-link { + font-size: 14px; + font-weight: normal; + color: var(--accent); + } + } + + .community-moderator { + display: flex; + gap: 10px; + padding: 8px 0; + align-items: center; + + .community-mod-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + } + + .community-mod-info { + display: flex; + flex-direction: column; + } + + .community-mod-name { + display: flex; + align-items: center; + font-weight: bold; + color: var(--fg_color); + + .verified-icon { + margin-left: 2px; + } + } + + .community-mod-username { + color: var(--fg_faded); + font-size: 14px; + } + } +} + +.community-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 15px; + border-bottom: 1px solid var(--border_grey); + + .community-tag { + display: inline-block; + background-color: var(--bg_elements); + border: 1px solid var(--border_grey); + border-radius: 16px; + padding: 4px 12px; + font-size: 13px; + color: var(--accent); + } +} + +.community-hashtag-header { + padding: 12px 15px; + border-bottom: 1px solid var(--border_grey); + + .community-hashtag-title { + font-size: 20px; + color: var(--accent); + margin: 0; + } +} diff --git a/src/types.nim b/src/types.nim index 05b7fb3..79a99c0 100644 --- a/src/types.nim +++ b/src/types.nim @@ -361,6 +361,23 @@ type members*: int banner*: string + CommunityRule* = object + name*: string + description*: string + + Community* = object + id*: string + name*: string + description*: string + memberCount*: int + banner*: string + creator*: User + category*: string + joinPolicy*: string + createdAt*: DateTime + rules*: seq[CommunityRule] + hashtags*: seq[string] + GlobalObjects* = ref object tweets*: Table[string, Tweet] users*: Table[string, User] diff --git a/src/views/community.nim b/src/views/community.nim new file mode 100644 index 0000000..52f9041 --- /dev/null +++ b/src/views/community.nim @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, strformat, times +import karax/[karaxdsl, vdom] + +import renderutils +import ".."/[types, utils, formatters] + +proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode = + let + path = &"/i/communities/{community.id}" + q = Query(kind: kind) + buildHtml(tdiv): + ul(class="tab"): + li(class=q.getTabClass(posts)): + a(href=path): text "Top" + li(class=q.getTabClass(replies)): + a(href=(path & "/latest")): text "Latest" + li(class=q.getTabClass(media)): + a(href=(path & "/media")): text "Media" + li(class=q.getTabClass(userList)): + a(href=(path & "/about")): text "About" + if community.hashtags.len > 0: + tdiv(class="community-tags"): + for tag in community.hashtags: + let bare = tag.strip(chars={'#'}) + a(class="community-tag", + href=(&"/i/communities/{community.id}/hashtag/{bare}")): + text tag + +proc renderMemberTabs*(community: Community; isModerators: bool): VNode = + let path = &"/i/communities/{community.id}" + buildHtml(ul(class="tab")): + li(class=(if not isModerators: "tab-item active" else: "tab-item")): + a(href=(path & "/members")): text "All" + li(class=(if isModerators: "tab-item active" else: "tab-item")): + a(href=(path & "/moderators")): text "Moderators" + +proc renderHashtagHeader*(community: Community; tag: string): VNode = + buildHtml(tdiv(class="community-hashtag-header")): + h2(class="community-hashtag-title"): text "#" & tag + +proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode = + buildHtml(tdiv(class="community-about")): + tdiv(class="community-info"): + h2: text "Community Info" + tdiv(class="community-info-item"): + icon "group" + if community.joinPolicy == "Open": + text "Anyone can join this Community." + else: + text "Membership is by approval only." + + tdiv(class="community-info-item"): + icon "info" + text "All Communities are publicly visible." + + tdiv(class="community-info-item"): + icon "calendar" + let + date = community.createdAt.format("MMMM d, yyyy") + creator = community.creator.username + span: + text &"Created {date} by " + a(href=(&"/{creator}")): text &"@{creator}" + if community.creator.verifiedType != none: + verifiedIcon(community.creator) + + if community.rules.len > 0: + tdiv(class="community-rules"): + h2: text "Rules" + p(class="community-rules-intro"): + text "These are set and enforced by Community admins and are in addition to " + a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules" + text "." + + for i, rule in community.rules: + tdiv(class="community-rule"): + span(class="community-rule-number"): text $(i + 1) + tdiv(class="community-rule-content"): + strong: text rule.name + if rule.description.len > 0: + p: text rule.description + + if moderators.len > 0: + tdiv(class="community-moderators"): + h2: + text "Moderators" + a(class="community-mods-link", + href=(&"/i/communities/{community.id}/moderators")): + text "See all" + for user in moderators: + tdiv(class="community-moderator"): + a(href=(&"/{user.username}")): + genImg(user.getUserPic("_bigger"), class="community-mod-avatar") + tdiv(class="community-mod-info"): + a(href=(&"/{user.username}"), class="community-mod-name"): + text user.fullname + if user.verifiedType != none: + verifiedIcon(user) + a(href=(&"/{user.username}"), class="community-mod-username"): + text &"@{user.username}" + +proc renderCommunity*(body, nav: VNode; community: Community): VNode = + buildHtml(tdiv(class="timeline-container")): + if community.banner.len > 0: + tdiv(class="timeline-banner"): + a(href=getPicUrl(community.banner), target="_blank"): + genImg(community.banner) + + tdiv(class="community-header"): + h1(class="community-name"): + a(href=(&"/i/communities/{community.id}")): text community.name + + if community.category.len > 0: + span(class="community-category"): text community.category + + if community.description.len > 0: + tdiv(class="community-description"): + text community.description + + tdiv(class="community-stats"): + a(class="community-member-count", + href=(&"/i/communities/{community.id}/members")): + text insertSep($community.memberCount, ',') + text " Members" + + nav + body diff --git a/src/views/general.nim b/src/views/general.nim index 13ac10d..2e515bb 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=38") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=39") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") if theme.len > 0: diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 0bd9789..8774f9d 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -18,7 +18,7 @@ proc getMediumPic*(url: string): string = result &= mediumWebp result = getPicUrl(result) -proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = +proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode = var c = "icon-" & icon if class.len > 0: c = &"{c} {class}" buildHtml(tdiv(class="icon-container")): @@ -27,8 +27,8 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = else: span(class=c, title=title) - if text.len > 0: - text " " & text + if label.len > 0: + text " " & label template verifiedIcon*(user: User): untyped {.dirty.} = if user.verifiedType != VerifiedType.none: diff --git a/src/views/timeline.nim b/src/views/timeline.nim index b911765..9456bf9 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -154,7 +154,8 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; else: renderThread(thread, prefs, path, bigThumb) else: for thread in filtered: - if thread.len == 1: renderTweet(thread[0], prefs, path) + if thread.len == 1: + renderTweet(thread[0], prefs, path) else: renderThread(thread, prefs, path) var cursor = getSearchMaxId(results, path) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 9bf6c9b..e658c8e 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -27,14 +27,18 @@ proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode = if preview.previewText.len > 0: p(class="card-description"): text preview.previewText -proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode = +proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs; + path = ""): VNode = buildHtml(tdiv): if pinned: + let pinnedLabel = + if "/i/communities/" in path: "Pinned by Community mods" + else: "Pinned Tweet" tdiv(class="pinned"): - span: icon "pin", "Pinned Tweet" + span: icon("pin", pinnedLabel) elif retweet.len > 0: tdiv(class="retweet-header"): - span: icon "retweet", retweet & " retweeted" + span: icon("retweet", retweet & " retweeted") tdiv(class="tweet-header"): a(class="tweet-avatar", href=("/" & tweet.user.username)): @@ -358,7 +362,8 @@ proc renderLocation*(tweet: Tweet): string = return $node proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; - last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode = + last=false; mainTweet=false; afterTweet=false; + bigThumb=false): VNode = var divClass = class if index == -1 or last: divClass = "thread-last " & class @@ -391,7 +396,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; a(class="tweet-link", href=getLink(tweet)) tdiv(class="tweet-body"): - renderHeader(tweet, retweet, pinned, prefs) + renderHeader(tweet, retweet, pinned, prefs, path) if not afterTweet and index == 0 and tweet.reply.len > 0 and (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned): diff --git a/temp b/temp deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_community.py b/tests/test_community.py new file mode 100644 index 0000000..b266044 --- /dev/null +++ b/tests/test_community.py @@ -0,0 +1,211 @@ +from base import BaseTestCase +from parameterized import parameterized + + +COMMUNITY_ID = '1493446837214187523' +COMMUNITY_PATH = f'i/communities/{COMMUNITY_ID}' + + +class CommunityTest(BaseTestCase): + def test_top_page_loads(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-header') + self.assert_text('Build in Public', '.community-name') + + def test_banner_visible(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.timeline-banner img') + + def test_member_count(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-member-count') + self.assert_text('Members', '.community-member-count') + + def test_description_visible(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-description') + + def test_tabs_present(self): + self.open_nitter(COMMUNITY_PATH) + tabs = self.find_elements('.tab a') + labels = [t.text for t in tabs] + self.assertEqual(labels, ['Top', 'Latest', 'Media', 'About']) + + def test_top_tab_active(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.tab .active a[href$="/' + COMMUNITY_ID + '"]') + + def test_top_has_tweets(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.timeline-item .tweet-body') + + def test_top_has_pagination(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.show-more') + self.assert_text('Load more', '.show-more') + + def test_latest_has_tweets(self): + self.open_nitter(f'{COMMUNITY_PATH}/latest') + self.assert_element_visible('.timeline-item .tweet-body') + + def test_latest_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/latest') + self.assert_element_visible('.tab .active a[href$="/latest"]') + + def test_media_has_tweets(self): + self.open_nitter(f'{COMMUNITY_PATH}/media') + self.assert_element_visible('.timeline-item .tweet-body') + + def test_media_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/media') + self.assert_element_visible('.tab .active a[href$="/media"]') + + def test_about_page(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_element_visible('.community-about') + self.assert_text('Community Info', '.community-info h2') + + def test_about_rules(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_element_visible('.community-rules') + self.assert_text('Rules', '.community-rules h2') + rules = self.find_elements('.community-rule') + self.assertGreater(len(rules), 0) + + def test_about_creator(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_text('Created', '.community-info') + link = self.find_element('.community-info-item a') + self.assertTrue(link.text.startswith('@')) + self.assertGreater(len(link.text), 1) + + def test_about_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_element_visible('.tab .active a[href$="/about"]') + + def test_about_moderators(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_element_visible('.community-moderators') + self.assert_text('Moderators', '.community-moderators h2') + mods = self.find_elements('.community-moderator') + self.assertGreater(len(mods), 0) + + def test_about_moderators_have_avatars(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + avatars = self.find_elements('.community-mod-avatar') + self.assertGreater(len(avatars), 0) + + def test_about_moderators_link_to_profiles(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + links = self.find_elements('.community-mod-username') + self.assertGreater(len(links), 0) + for link in links: + self.assertTrue(link.text.startswith('@')) + self.assertTrue(link.get_attribute('href').startswith('http')) + + def test_about_see_all_link(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + link = self.find_element('.community-mods-link') + self.assertEqual(link.text, 'See all') + self.assertIn('/moderators', link.get_attribute('href')) + + def test_members_page(self): + self.open_nitter(f'{COMMUNITY_PATH}/members') + self.assert_element_visible('.timeline-item') + users = self.find_elements('.timeline-item .username') + self.assertGreater(len(users), 0) + + def test_members_has_member_tabs(self): + self.open_nitter(f'{COMMUNITY_PATH}/members') + tabs = self.find_elements('.tab a') + labels = [t.text for t in tabs] + self.assertEqual(labels, ['All', 'Moderators']) + + def test_members_all_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/members') + self.assert_element_visible('.tab .active a[href$="/members"]') + + def test_members_count_is_link(self): + self.open_nitter(COMMUNITY_PATH) + link = self.find_element('.community-member-count') + self.assertIn('Members', link.text) + self.assertIn('/members', link.get_attribute('href')) + + def test_moderators_page(self): + self.open_nitter(f'{COMMUNITY_PATH}/moderators') + self.assert_element_visible('.timeline-item') + users = self.find_elements('.timeline-item .username') + self.assertGreater(len(users), 0) + + def test_moderators_tab_active(self): + self.open_nitter(f'{COMMUNITY_PATH}/moderators') + self.assert_element_visible('.tab .active a[href$="/moderators"]') + + def test_moderators_has_member_tabs(self): + self.open_nitter(f'{COMMUNITY_PATH}/moderators') + tabs = self.find_elements('.tab a') + labels = [t.text for t in tabs] + self.assertEqual(labels, ['All', 'Moderators']) + + def test_pinned_tweet_label(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.pinned') + self.assert_text('Pinned by Community mods', '.pinned') + + def test_hashtags_visible(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-tags') + tags = self.find_elements('.community-tag') + self.assertGreater(len(tags), 0) + + def test_hashtags_are_links(self): + self.open_nitter(COMMUNITY_PATH) + tags = self.find_elements('.community-tag') + for tag in tags: + href = tag.get_attribute('href') + self.assertIn('/hashtag/', href) + self.assertTrue(tag.text.startswith('#')) + + def test_hashtag_page(self): + self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') + self.assert_element_visible('.timeline-item .tweet-body') + + def test_hashtag_shows_header(self): + self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') + self.assert_element_visible('.community-header') + self.assert_text('Build in Public', '.community-name') + + def test_hashtag_shows_tag_title(self): + self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') + self.assert_element_visible('.community-hashtag-header') + self.assert_text('#buildinpublic', '.community-hashtag-title') + + def test_hashtag_no_main_tabs(self): + self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') + tabs = self.find_elements('.tab a') + tab_labels = [t.text for t in tabs] + self.assertNotIn('Top', tab_labels) + self.assertNotIn('About', tab_labels) + + def test_category_visible(self): + self.open_nitter(COMMUNITY_PATH) + self.assert_element_visible('.community-category') + + def test_about_join_policy(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_text('Anyone can join', '.community-info') + + def test_about_visibility_note(self): + self.open_nitter(f'{COMMUNITY_PATH}/about') + self.assert_text('publicly visible', '.community-info') + + def test_404_invalid_id(self): + self.open_nitter('i/communities/999') + self.assert_element_visible('.error-panel') + self.assert_text('not found', '.error-panel') + + @parameterized.expand(['', '/latest', '/media', '/about', + '/members', '/moderators']) + def test_page_no_error(self, suffix): + self.open_nitter(f'{COMMUNITY_PATH}{suffix}') + self.assert_element_not_visible('.error-panel') From 44b2f096f67da2cc257a0e262a94a7ae79e95d47 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 18 Jun 2026 15:01:48 +0200 Subject: [PATCH 244/309] Fix SSRF in /video proxy and API JSON injection Validate the target host on the /video media route with isTwitterUrl() (mirroring /pic) and reject non-http(s) schemes, and stop the media proxy following redirects off the validated host. JSON-escape user-controlled GraphQL cursors and build id variables with packedjson so untrusted input can't break out of the query. Warn on startup when the insecure default hmacKey is in use. Fixes #1411 --- nitter.example.conf | 2 +- src/api.nim | 33 +++++++++++++++++++-------------- src/consts.nim | 2 -- src/nitter.nim | 4 ++++ src/routes/media.nim | 8 +++++--- src/utils.nim | 4 ++-- tests/test_ssrf_1411.nim | 38 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 69 insertions(+), 22 deletions(-) create mode 100644 tests/test_ssrf_1411.nim diff --git a/nitter.example.conf b/nitter.example.conf index a58c63c..32fb618 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -20,7 +20,7 @@ redisMaxConnections = 30 # you receive tons of requests per second [Config] -hmacKey = "secretkey" # random key for cryptographic signing of video urls +hmacKey = "secretkey" # CHANGE THIS to a unique random value (e.g. `openssl rand -hex 32`); signs media urls base64Media = false # use base64 encoding for proxied media urls enableRSS = true # master switch, set to false to disable all RSS feeds enableRSSUserTweets = true # /@user/rss diff --git a/src/api.nim b/src/api.nim index 9aa6c50..4dd6a3d 100644 --- a/src/api.nim +++ b/src/api.nim @@ -18,6 +18,11 @@ proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): Ap let url = apiUrl(endpoint, variables, fieldToggles, skipTid) return ApiReq(cookie: url, oauth: url) +proc cursorParam(after: string): string = + ## JSON-escape the user-supplied cursor so it cannot break out of the GraphQL + ## variables object (same input-validation class as the #1411 media SSRF). + if after.len > 0: "\"cursor\":" & $(%after) & "," else: "" + proc mediaUrl(id, cursor: string; count=20): ApiReq = result = ApiReq( cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]), @@ -39,10 +44,10 @@ proc tweetDetailUrl(id: string; cursor: string): ApiReq = # ) proc userUrl(username: string): ApiReq = - let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username + let cookieVars = $(%*{"screen_name": username, "withGrokTranslatedBio": false}) result = ApiReq( cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles), - oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username) + oauth: apiUrl(graphUserV2, $(%*{"screen_name": username})) ) proc getGraphUser*(username: string): Future[User] {.async.} = @@ -60,7 +65,7 @@ proc getGraphUserById*(id: string): Future[User] {.async.} = proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} = if username.len == 0: return let - url = apiReq(graphAboutAccount, """{"screenName":"$1"}""" % username) + url = apiReq(graphAboutAccount, $(%*{"screenName": username})) js = await fetch(url) result = parseAboutAccount(js) @@ -71,7 +76,7 @@ proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq = proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} = if id.len == 0: return let - req = apiReq(graphBroadcast, """{"id":"$1"}""" % id) + req = apiReq(graphBroadcast, $(%*{"id": id})) js = await fetch(req) result = parseBroadcastInfo(js) @@ -86,7 +91,7 @@ proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + cursor = cursorParam(after) url = case kind of TimelineKind.tweets: userTweetsUrl(id, cursor) of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) @@ -97,14 +102,14 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi proc getGraphCommunity*(id: string): Future[Community] {.async.} = if id.len == 0: return let - url = apiReq(graphCommunity, communityVars % id) + url = apiReq(graphCommunity, $(%*{"communityId": id})) js = await fetch(url) result = parseGraphCommunity(js) proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + cursor = cursorParam(after) url = apiReq(graphCommunityTweets, communityTweetsVars % [id, cursor, rankingMode]) js = await fetch(url) result = parseGraphCommunityTimeline(js, after) @@ -112,7 +117,7 @@ proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future proc getGraphCommunityMedia*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + cursor = cursorParam(after) url = apiReq(graphCommunityMedia, communityMediaVars % [id, cursor]) js = await fetch(url) result = parseGraphCommunityTimeline(js, after) @@ -124,7 +129,7 @@ proc communitySliceReq(endpoint, variables: string): ApiReq = proc getGraphCommunityMembers*(id: string; after=""): Future[Result[User]] {.async.} = if id.len == 0: return let - cursor = if after.len > 0: "\"$1\"" % after else: "null" + cursor = if after.len > 0: $(%after) else: "null" url = communitySliceReq(graphCommunityMembers, communityMembersVars % [id, cursor]) js = await fetch(url) result = parseGraphCommunityMembers(js, after) @@ -140,7 +145,7 @@ proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] if id.len == 0 or hashtag.len == 0: return let safeTag = multiReplace(hashtag, ("\"", ""), ("\\", "")) - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + cursor = cursorParam(after) url = apiReq(graphCommunityHashtags, communityHashtagsVars % [id, cursor, safeTag]) js = await fetch(url) result = parseGraphCommunityTimeline(js, after) @@ -148,7 +153,7 @@ proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + cursor = cursorParam(after) url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"]) js = await fetch(url) result = parseGraphTimeline(js, after).tweets @@ -162,7 +167,7 @@ proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} = let - url = apiReq(graphListById, """{"listId": "$1"}""" % id) + url = apiReq(graphListById, $(%*{"listId": id})) js = await fetch(url) result = parseGraphList(js) @@ -186,14 +191,14 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let - url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id) + url = apiReq(graphTweetResult, $(%*{"rest_id": id})) js = await fetch(url) result = parseGraphTweetResult(js) proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = if id.len == 0: return let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + cursor = cursorParam(after) js = await fetch(tweetDetailUrl(id, cursor)) result = parseGraphConversation(js, id) diff --git a/src/consts.nim b/src/consts.nim index 59b986c..fead5bf 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -161,8 +161,6 @@ const articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}""" - communityVars* = """{"communityId":"$1"}""" - communityTweetsVars* = """{ "communityId": "$1", $2 "count": 20, diff --git a/src/nitter.nim b/src/nitter.nim index 955849b..d629f6d 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -34,6 +34,10 @@ stdout.flushFile updateDefaultPrefs(fullCfg) setCacheTimes(cfg) setHmacKey(cfg.hmacKey) +if cfg.hmacKey.len == 0 or cfg.hmacKey == "secretkey": + stderr.write "WARNING: insecure default 'hmacKey' in nitter.conf; " & + "set a unique random value to stop media URL signatures being forgeable.\n" + stderr.flushFile setProxyEncoding(cfg.base64Media) setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) diff --git a/src/routes/media.nim b/src/routes/media.nim index df30d5f..40f5a6c 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -15,7 +15,9 @@ const maxAge* = "max-age=604800" proc safeFetch*(url: string): Future[string] {.async.} = - let client = newAsyncHttpClient() + # maxRedirects=0: the caller already validated the host, so never follow a + # redirect off the allowlisted host (would re-open the #1411 SSRF). + let client = newAsyncHttpClient(maxRedirects = 0) try: result = await client.getContent(url) except: discard finally: client.close() @@ -32,7 +34,7 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = result = Http200 let request = req.getNativeReq() - client = newAsyncHttpClient() + client = newAsyncHttpClient(maxRedirects = 0) try: let res = await client.get(url) @@ -122,7 +124,7 @@ proc createMediaRouter*(cfg: Config) = get re"^\/video\/(enc)?\/?(.+)\/(.+)$": let url = decoded(request, 2) - cond "http" in url + cond isTwitterUrl(url) if getHmac(url) != request.matches[1]: resp Http403, showError("Failed to verify signature", cfg) diff --git a/src/utils.nim b/src/utils.nim index 391e2a3..f4ae560 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -57,8 +57,8 @@ proc filterParams*(params: Table): seq[(string, string)] = result.add p proc isTwitterUrl*(uri: Uri): bool = - uri.hostname in twitterDomains or - uri.hostname.endsWith(".video.pscp.tv") + uri.scheme in ["http", "https"] and + (uri.hostname in twitterDomains or uri.hostname.endsWith(".video.pscp.tv")) proc isTwitterUrl*(url: string): bool = isTwitterUrl(parseUri(url)) diff --git a/tests/test_ssrf_1411.nim b/tests/test_ssrf_1411.nim new file mode 100644 index 0000000..0cb4bf2 --- /dev/null +++ b/tests/test_ssrf_1411.nim @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Reproduction + regression test for issue #1411: +# SSRF via /video proxy with default HMAC key and missing host validation. +import std/[unittest, uri] +import ".."/src/utils + +suite "issue #1411 SSRF via /video proxy": + setup: + # The default key shipped in nitter.example.conf / config.nim. + setHmacKey("secretkey") + + test "HMAC for arbitrary SSRF URLs is forgeable with the default key": + # These signatures were independently computed (Python hmac-sha256, uppercase + # hex, first 13 chars) and observed live in the issue report. + check getHmac("http://172.17.0.1:19999/secret_data.m3u8") == "BBD19ACC6C012" + check getHmac("http://172.17.0.1:19999/secret_data.mp4") == "0780F00DDF3E7" + + test "isTwitterUrl rejects SSRF targets (the guard /video is missing)": + # Internal / metadata hosts an attacker would target. + check isTwitterUrl(parseUri("http://172.17.0.1:19999/secret_data.m3u8")) == false + check isTwitterUrl(parseUri("http://169.254.169.254/latest/meta-data/x.m3u8")) == false + check isTwitterUrl(parseUri("http://localhost/x.mp4")) == false + check isTwitterUrl(parseUri("http://[::1]/x.mp4")) == false + + test "isTwitterUrl rejects userinfo / look-alike host bypass attempts": + check isTwitterUrl(parseUri("http://video.twimg.com@169.254.169.254/x.mp4")) == false + check isTwitterUrl(parseUri("http://video.twimg.com.evil.com/x.mp4")) == false + check isTwitterUrl(parseUri("http://evilvideo.twimg.com.attacker/x.mp4")) == false + + test "isTwitterUrl rejects non-http schemes even on a Twitter host": + check isTwitterUrl(parseUri("gopher://video.twimg.com/x.mp4")) == false + check isTwitterUrl(parseUri("file:///etc/passwd")) == false + check isTwitterUrl(parseUri("ftp://video.twimg.com/x.mp4")) == false + + test "isTwitterUrl still allows legitimate Twitter video hosts": + check isTwitterUrl(parseUri("https://video.twimg.com/ext_tw_video/1/pu/pl/x.m3u8")) == true + check isTwitterUrl(parseUri("https://video.twimg.com/amplify_video/1/vid/x.mp4")) == true + check isTwitterUrl(parseUri("https://prod-fastly-us-east-1.video.pscp.tv/x.m3u8")) == true From bfcf75af7fbe7a17274b32edb611eee148969522 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 18 Jun 2026 15:42:17 +0200 Subject: [PATCH 245/309] Fix invalid/empty image hrefs Fixes #1413 --- src/utils.nim | 2 ++ src/views/general.nim | 1 + src/views/renderutils.nim | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/utils.nim b/src/utils.nim index f4ae560..95b46de 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -40,12 +40,14 @@ proc getVidUrl*(link: string): string = &"/video/{sig}/{encodeUrl(link)}" proc getPicUrl*(link: string): string = + if link.len == 0: return if base64Media: &"/pic/enc/{encode(link, safe=true)}" else: &"/pic/{encodeUrl(link)}" proc getOrigPicUrl*(link: string): string = + if link.len == 0: return if base64Media: &"/pic/orig/enc/{encode(link, safe=true)}" else: diff --git a/src/views/general.nim b/src/views/general.nim index 2e515bb..d3ba088 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -97,6 +97,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="preload", type="image/png", href=bannerUrl, `as`="image") for url in images: + if url.len == 0: continue let preloadUrl = if "400x400" in url: getPicUrl(url) else: getSmallPic(url) link(rel="preload", type="image/png", href=preloadUrl, `as`="image") diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 8774f9d..af2f05d 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -7,12 +7,14 @@ const smallWebp* = "?name=small&format=webp" const mediumWebp* = "?name=medium&format=webp" proc getSmallPic*(url: string): string = + if url.len == 0: return result = url if "?" notin url and not url.endsWith("placeholder.png"): result &= smallWebp result = getPicUrl(result) proc getMediumPic*(url: string): string = + if url.len == 0: return result = url if "?" notin url and not url.endsWith("placeholder.png"): result &= mediumWebp From 581c6f1714b64e0c6e24bf47c238821803638966 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 19 Jun 2026 22:00:28 +0200 Subject: [PATCH 246/309] Add retry logic and timeout to media proxy --- src/routes/media.nim | 98 +++++++++++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 37 deletions(-) diff --git a/src/routes/media.nim b/src/routes/media.nim index 40f5a6c..0fa02aa 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import uri, strutils, httpclient, os, hashes, base64, re import asynchttpserver, asyncstreams, asyncfile, asyncnet +import asyncdispatch import jester @@ -32,47 +33,70 @@ template respond*(req: asynchttpserver.Request; headers) = proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = result = Http200 - let - request = req.getNativeReq() - client = newAsyncHttpClient(maxRedirects = 0) + let request = req.getNativeReq() - try: - let res = await client.get(url) - if res.status != "200 OK": - if res.status != "404 Not Found": - echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] - 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] + for attempt in 0 .. 2: + let client = newAsyncHttpClient(maxRedirects = 0) + var shouldRetry = false + try: + let resFut = client.get(url) + let completed = await withTimeout(resFut, 5000) + if not completed: + if attempt < 2: + echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), url] + shouldRetry = true + else: + echo "[media] Proxying timeout after 5s, url: $1" % [url] + return Http504 else: - "" + let res = resFut.read() + if res.status != "200 OK": + if res.status == "404 Not Found": + return Http404 + if attempt < 2: + echo "[media] Retry $1/2, status: $2, url: $3" % [$(attempt + 1), res.status, url] + shouldRetry = true + else: + echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] + return Http404 + else: + let hashed = $hash(url) + if request.headers.getOrDefault("If-None-Match") == hashed: + return Http304 - let headers = newHttpHeaders({ - "content-type": res.headers["content-type", 0], - "content-length": contentLength, - "cache-control": maxAge, - "etag": hashed - }) + let contentLength = + if res.headers.hasKey("content-length"): + res.headers["content-length", 0] + else: + "" - respond(request, headers) + let headers = newHttpHeaders({ + "content-type": res.headers["content-type", 0], + "content-length": contentLength, + "cache-control": maxAge, + "etag": hashed + }) - var (hasValue, data) = (true, "") - while hasValue: - (hasValue, data) = await res.bodyStream.read() - if hasValue: - await request.client.send(data) - data.setLen 0 - except HttpRequestError, ProtocolError, OSError: - echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] - result = Http404 - finally: - client.close() + respond(request, headers) + + var (hasValue, data) = (true, "") + while hasValue: + (hasValue, data) = await res.bodyStream.read() + if hasValue: + await request.client.send(data) + data.setLen 0 + return Http200 + except CatchableError: + if attempt < 2: + echo "[media] Retry $1/2, error: $2, url: $3" % [$(attempt + 1), getCurrentExceptionMsg(), url] + shouldRetry = true + else: + echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] + result = Http404 + finally: + client.close() + if not shouldRetry: + break template check*(code): untyped = if code != Http200: @@ -129,7 +153,7 @@ proc createMediaRouter*(cfg: Config) = if getHmac(url) != request.matches[1]: resp Http403, showError("Failed to verify signature", cfg) - if ".mp4" in url or ".ts" in url or ".m4s" in url: + if ".mp4" in url or ".ts" in url or ".m4s" in url or ".aac" in url: let code = await proxyMedia(request, url) check code From 17ad0fec34bf1aff97efcf9079adc01fa44119ba Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 19 Jun 2026 22:00:31 +0200 Subject: [PATCH 247/309] Add audio playback support to HLS.js handler --- public/js/hlsPlayback.js | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/public/js/hlsPlayback.js b/public/js/hlsPlayback.js index 9919fec..5970011 100644 --- a/public/js/hlsPlayback.js +++ b/public/js/hlsPlayback.js @@ -1,27 +1,30 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only -function playVideo(overlay) { - const video = overlay.parentElement.querySelector('video'); - const url = video.getAttribute("data-url"); - const startTime = parseFloat(video.getAttribute("data-start") || "0"); - video.setAttribute("controls", ""); +function playMedia(overlay, tagName) { + const media = overlay.parentElement.querySelector(tagName); + const url = media.getAttribute("data-url"); + const startTime = parseFloat(media.getAttribute("data-start") || "0"); + media.setAttribute("controls", ""); overlay.style.display = "none"; if (Hls.isSupported()) { var hls = new Hls({autoStartLoad: false}); hls.loadSource(url); - hls.attachMedia(video); + hls.attachMedia(media); hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.loadLevel = hls.levels.length - 1; hls.startLoad(startTime); - video.play(); + media.play(); }); - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = url; - video.addEventListener('canplay', function() { - if (startTime > 0) video.currentTime = startTime; - video.play(); + } else if (media.canPlayType('application/vnd.apple.mpegurl')) { + media.src = url; + media.addEventListener('canplay', function() { + if (startTime > 0) media.currentTime = startTime; + media.play(); }); } } + +function playVideo(overlay) { playMedia(overlay, 'video'); } +function playAudio(overlay) { playMedia(overlay, 'audio'); } // @license-end From db5a229bfe2b2c376cc452dd0266557bcf25aae7 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 19 Jun 2026 22:00:36 +0200 Subject: [PATCH 248/309] Add Twitter Spaces support Fixes #459 --- src/api.nim | 13 ++++ src/consts.nim | 1 + src/nitter.nim | 4 +- src/parser.nim | 53 ++++++++++++++- src/redis_cache.nim | 15 +++++ src/routes/space.nim | 36 ++++++++++ src/sass/_space.scss | 149 ++++++++++++++++++++++++++++++++++++++++++ src/sass/index.scss | 1 + src/types.nim | 22 +++++++ src/views/general.nim | 2 +- src/views/space.nim | 86 ++++++++++++++++++++++++ 11 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 src/routes/space.nim create mode 100644 src/sass/_space.scss create mode 100644 src/views/space.nim diff --git a/src/api.nim b/src/api.nim index 4dd6a3d..f2085df 100644 --- a/src/api.nim +++ b/src/api.nim @@ -88,6 +88,19 @@ proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} = result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr( streamJs{"source", "location"}.getStr) +proc getAudioSpace*(id: string): Future[AudioSpace] {.async.} = + if id.len == 0: return + let + variables = %*{ + "id": id, + "isMetatagsQuery": false, + "withReplays": true, + "withListeners": true + } + req = apiReq(graphAudioSpace, $variables) + js = await fetch(req) + result = parseAudioSpace(js) + proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let diff --git a/src/consts.nim b/src/consts.nim index fead5bf..8ec4f04 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -39,6 +39,7 @@ const graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds" graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery" + graphAudioSpace* = "rWRLsOhNJ2xjpI1tREYurQ/AudioSpaceById" restLiveStream* = "1.1/live_video_stream/status/" gqlFeatures* = """{ diff --git a/src/nitter.nim b/src/nitter.nim index d629f6d..dc38161 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, community, debug, - unsupported, embed, resolver, broadcast, article, router_utils] + unsupported, embed, resolver, broadcast, space, article, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -65,6 +65,7 @@ createMediaRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) createBroadcastRouter(cfg) +createSpaceRouter(cfg) createDebugRouter(cfg) settings: @@ -136,5 +137,6 @@ routes: extend resolver, "" extend embed, "" extend broadcastRoute, "" + extend spaceRoute, "" extend debug, "" extend unsupported, "" diff --git a/src/parser.nim b/src/parser.nim index c810f76..04a7d33 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -131,6 +131,51 @@ proc parseBroadcastInfo*(js: JsonNode): Broadcast = user: parseGraphUser(bc) ) +proc parseSpaceParticipant(js: JsonNode): SpaceParticipant = + result = SpaceParticipant( + userId: js{"user_results", "rest_id"}.getStr, + username: js{"twitter_screen_name"}.getStr, + displayName: js{"display_name"}.getStr, + avatarUrl: js{"avatar_url"}.getStr, + isVerified: js{"is_verified"}.getBool or + js{"user_results", "result", "is_blue_verified"}.getBool + ) + +proc parseAudioSpace*(js: JsonNode): AudioSpace = + let space = ? js{"data", "audioSpace"} + let meta = space{"metadata"} + + result = AudioSpace( + id: meta{"rest_id"}.getStr, + title: meta{"title"}.getStr, + state: meta{"state"}.getStr.toUpperAscii, + mediaKey: meta{"media_key"}.getStr, + totalLiveListeners: meta{"total_live_listeners"}.getInt, + totalReplayWatched: meta{"total_replay_watched"}.getInt, + availableForReplay: meta{"is_space_available_for_replay"}.getBool + ) + + let startedAt = meta{"started_at"}.getInt(0) + if startedAt > 0: + result.startTime = fromUnix(startedAt div 1000).utc() + + let endedAtStr = meta{"ended_at"}.getStr + if endedAtStr.len > 0: + try: + let endedAt = parseBiggestInt(endedAtStr) + if endedAt > 0: + result.endTime = fromUnix(endedAt div 1000).utc() + except ValueError: + discard + + result.creator = parseGraphUser(meta{"creator_results", "result"}) + + for admin in space{"participants", "admins"}: + result.admins.add parseSpaceParticipant(admin) + + for speaker in space{"participants", "speakers"}: + result.speakers.add parseSpaceParticipant(speaker) + proc parseGraphCommunity*(js: JsonNode): Community = if js.isNull: return let c = ? js{"data", "communityResults", "result"} @@ -390,7 +435,13 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = result.url = vals{"player_url"}.getStrVal if "youtube.com" in result.url: result.url = result.url.replace("/embed/", "/watch?v=") - of audiospace, unknown: + of audiospace: + let spaceId = vals{"id"}.getStrVal + if spaceId.len > 0: + result.url = "/i/spaces/" & spaceId + result.title = "Twitter Space" + result.text = "Click to view Space" + of unknown: result.title = "This card type is not supported." else: discard diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 4d5bf28..b9ddbcc 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -173,6 +173,21 @@ proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} = await cache(result) result.m3u8Url = await fetchBroadcastStream(result.mediaKey) +proc cache*(data: AudioSpace) {.async.} = + if data.id.len == 0: return + let ttl = if data.state == "RUNNING": baseCacheTime div 6 else: baseCacheTime + await setEx("sp:" & data.id, ttl, compress(toFlatty(data))) + +proc getCachedAudioSpace*(id: string): Future[AudioSpace] {.async.} = + if id.len == 0: return + let cached = await get("sp:" & id) + if cached != redisNil: + cached.deserialize(AudioSpace) + else: + result = await getAudioSpace(id) + await cache(result) + result.m3u8Url = await fetchBroadcastStream(result.mediaKey) + proc cache*(data: AccountInfo; name: string) {.async.} = await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data))) diff --git a/src/routes/space.nim b/src/routes/space.nim new file mode 100644 index 0000000..bd956ea --- /dev/null +++ b/src/routes/space.nim @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import asyncdispatch, strutils +import jester + +import router_utils +import ".."/[types, formatters, redis_cache] +import ../views/[general, space] +import media + +export space + +proc createSpaceRouter*(cfg: Config) = + router spaceRoute: + get "/i/spaces/@id": + cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) + let sp = await getCachedAudioSpace(@"id") + + if sp.id.len == 0: + resp Http404, showError("Space not found", cfg) + + let prefs = requestPrefs() + resp renderMain(renderSpace(sp, prefs, request.path), request, cfg, prefs, + sp.title, ogTitle=sp.title) + + get "/i/spaces/@id/stream": + cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) + let sp = await getCachedAudioSpace(@"id") + + if sp.m3u8Url.len == 0: + resp Http404 + + let manifest = await safeFetch(sp.m3u8Url) + if manifest.len == 0: + resp Http502 + + resp proxifyVideo(manifest, requestPrefs().proxyVideos, sp.m3u8Url), m3u8Mime diff --git a/src/sass/_space.scss b/src/sass/_space.scss new file mode 100644 index 0000000..5fe2e7e --- /dev/null +++ b/src/sass/_space.scss @@ -0,0 +1,149 @@ +.space-page { + max-width: 800px; + width: 100%; + margin: 20px auto 0; +} + +.space-panel { + background-color: var(--bg_panel); + border: 1px solid var(--border_grey); + border-radius: 8px; + overflow: hidden; +} + +.space-player { + position: relative; + background: linear-gradient(135deg, #7b2a8c 0%, #9b3ab1 100%); + min-height: 140px; + display: flex; + align-items: center; + justify-content: center; + + audio { + width: 100%; + padding: 15px; + box-sizing: border-box; + + &:not([controls]) { + display: none; + } + } + + .video-overlay { + background-color: transparent; + } +} + +.space-live { + background: #e0245e; + color: white; + padding: 3px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 12px; + text-transform: uppercase; + position: absolute; + top: 8px; + right: 8px; +} + +.space-info { + padding: 16px; +} + +.space-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + +.space-title { + font-size: 18px; + font-weight: bold; + margin: 0; + line-height: 1.3; + flex: 1; +} + +.space-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; + font-size: 14px; + color: var(--fg_faded); +} + +.listener-count { + color: var(--fg_color); +} + +.space-state { + color: var(--fg_dark); +} + +.space-participants { + border-top: 1px solid var(--border_grey); + padding-top: 12px; +} + +.space-participant { + margin-bottom: 10px; + + a { + display: flex; + align-items: center; + gap: 10px; + color: var(--fg_color); + padding: 6px 0; + } + + img { + width: 40px; + height: 40px; + border-radius: 50%; + flex-shrink: 0; + } +} + +.participant-info { + min-width: 0; +} + +.participant-name { + display: flex; + align-items: center; + gap: 4px; + + strong { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .verified-icon { + margin-bottom: 0; + position: relative; + top: -2px; + } +} + +.host-badge { + background: var(--accent); + color: white; + padding: 2px 7px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + line-height: 1; + position: relative; + top: 1px; +} + +.participant-username { + color: var(--fg_dark); + font-size: 13px; +} diff --git a/src/sass/index.scss b/src/sass/index.scss index b8c9a8c..404f7d5 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -8,6 +8,7 @@ @import "timeline"; @import "search"; @import "broadcast"; +@import "space"; @import "_article"; body { diff --git a/src/types.nim b/src/types.nim index 79a99c0..f51f349 100644 --- a/src/types.nim +++ b/src/types.nim @@ -128,6 +128,28 @@ type availableForReplay*: bool user*: User + SpaceParticipant* = object + userId*: string + username*: string + displayName*: string + avatarUrl*: string + isVerified*: bool + + AudioSpace* = object + id*: string + title*: string + state*: string + mediaKey*: string + m3u8Url*: string + totalLiveListeners*: int + totalReplayWatched*: int + startTime*: DateTime + endTime*: DateTime + availableForReplay*: bool + creator*: User + admins*: seq[SpaceParticipant] + speakers*: seq[SpaceParticipant] + VideoType* = enum m3u8 = "application/x-mpegURL" mp4 = "video/mp4" diff --git a/src/views/general.nim b/src/views/general.nim index d3ba088..c273479 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=39") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=42") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") if theme.len > 0: diff --git a/src/views/space.nim b/src/views/space.nim new file mode 100644 index 0000000..a5cac7b --- /dev/null +++ b/src/views/space.nim @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, times +import karax/[karaxdsl, vdom] + +import renderutils +import ".."/[types, utils, formatters] + +proc renderParticipant(p: SpaceParticipant; role: string): VNode = + buildHtml(tdiv(class="space-participant")): + a(href=("/" & p.username)): + genImg(p.avatarUrl.replace("_normal", "_bigger")) + tdiv(class="participant-info"): + tdiv(class="participant-name"): + strong: text p.displayName + if p.isVerified: + tdiv(class="verified-icon blue"): + icon "circle", class="verified-icon-circle", title="Verified account" + icon "ok", class="verified-icon-check", title="Verified account" + if role.len > 0: + span(class="host-badge"): text role + span(class="participant-username"): text "@" & p.username + +proc renderSpace*(sp: AudioSpace; prefs: Prefs; path: string): VNode = + let + isLive = sp.state == "RUNNING" + source = if prefs.proxyVideos and sp.m3u8Url.startsWith("http"): + getVidUrl(sp.m3u8Url) else: sp.m3u8Url + stateText = + if isLive: "LIVE" + elif sp.endTime.year > 1: "Ended " & sp.endTime.format("MMM d, YYYY") + elif sp.state.len > 0: sp.state + else: "Ended" + durationMs = + if sp.startTime.year > 1 and sp.endTime.year > 1: + int((sp.endTime - sp.startTime).inMilliseconds) + else: 0 + duration = if durationMs > 0: getDuration(durationMs) else: "" + totalListeners = + if sp.totalReplayWatched > 0: sp.totalReplayWatched + else: sp.totalLiveListeners + + buildHtml(tdiv(class="space-page")): + tdiv(class="space-panel"): + tdiv(class="space-player"): + if sp.m3u8Url.len > 0 and prefs.hlsPlayback: + audio(data-url=source, data-autoload="false") + verbatim "
" + tdiv(class="overlay-circle"): span(class="overlay-triangle") + if isLive: + tdiv(class="space-live"): text "LIVE" + elif duration.len > 0: + tdiv(class="overlay-duration"): text duration + verbatim "
" + elif sp.m3u8Url.len > 0: + tdiv(class="video-overlay"): + buttonReferer "/enablehls", "Enable hls playback", path + if isLive: + tdiv(class="space-live"): text "LIVE" + elif duration.len > 0: + tdiv(class="overlay-duration"): text duration + elif sp.availableForReplay: + tdiv(class="video-overlay"): + p: text "Audio stream unavailable" + else: + tdiv(class="video-overlay"): + p: text "Replay is not available" + + tdiv(class="space-info"): + tdiv(class="space-header"): + h2(class="space-title"): text sp.title + tdiv(class="space-meta"): + if totalListeners > 0: + span(class="listener-count"): text insertSep($totalListeners, ',') & " listeners" + if isLive: + span(class="space-live"): text stateText + else: + span(class="space-state"): text stateText + + if sp.admins.len > 0 or sp.speakers.len > 0: + tdiv(class="space-participants"): + for admin in sp.admins: + let role = if admin.username == sp.creator.username: "Host" + else: "Co-host" + renderParticipant(admin, role) + for speaker in sp.speakers: + renderParticipant(speaker, "") From 96a850c96336e25b518263bb8e155cbea44b4ece Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 19 Jun 2026 22:00:40 +0200 Subject: [PATCH 249/309] Add Space tests and fix media test selectors --- tests/test_space.py | 119 ++++++++++++++++++++++++++++++++++++++ tests/test_thread.py | 11 +--- tests/test_tweet_media.py | 12 ++-- 3 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 tests/test_space.py diff --git a/tests/test_space.py b/tests/test_space.py new file mode 100644 index 0000000..d2ba672 --- /dev/null +++ b/tests/test_space.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""Integration tests for Twitter Spaces support.""" +import pytest +from seleniumbase import BaseCase + + +class TestSpacePage(BaseCase): + """Tests for /i/spaces/@id route.""" + + SPACE_ID = "1mxPaaRAwYjKN" + SPACE_URL = f"http://localhost:8080/i/spaces/{SPACE_ID}" + + def test_space_page_loads(self): + """Space page should load with title.""" + self.open(self.SPACE_URL) + self.assert_element(".space-page") + self.assert_element(".space-panel") + self.assert_text_visible("INTEL WILL MOON NEXT WEEK", ".space-title") + + def test_space_host_info(self): + """Space should display host in participants.""" + self.open(self.SPACE_URL) + self.assert_element(".space-participants") + self.assert_text_visible("bubble boi") + self.assert_element(".host-badge") + + def test_space_metadata(self): + """Space should display listener count and state.""" + self.open(self.SPACE_URL) + self.assert_element(".space-meta") + # Should show listener count (number format) + meta_text = self.get_text(".space-meta") + assert any(c.isdigit() for c in meta_text), "Should show listener count" + # Should show ended state + assert "Ended" in meta_text or "Jun" in meta_text, "Should show ended state" + + def test_space_participants(self): + """Space should display host and speakers.""" + self.open(self.SPACE_URL) + self.assert_element(".space-participants") + # Host should have badge + self.assert_element(".host-badge") + self.assert_text_visible("Host", ".host-badge") + # Should show speakers + self.assert_text_visible("CANTELOPEPEEL") + self.assert_text_visible("Based Burner Account") + self.assert_text_visible("anon invests") + + def test_space_participant_avatars(self): + """Participant avatars should load correctly.""" + self.open(self.SPACE_URL) + # Check avatars in participants section + avatars = self.find_elements(".space-participant img") + assert len(avatars) >= 4, "Should have at least 4 participant avatars" + for avatar in avatars: + src = avatar.get_attribute("src") + # Should NOT be double-encoded + assert "%2Fpic%2F" not in src, f"Avatar URL double-encoded: {src}" + # Should have valid path + assert "/pic/" in src, f"Avatar URL missing /pic/: {src}" + + def test_space_player_hls_disabled(self): + """Without HLS, should show enable button with video-overlay style.""" + self.open(self.SPACE_URL) + self.assert_element(".space-player") + self.assert_element(".video-overlay") + # Should show duration in overlay-duration + self.assert_element(".overlay-duration") + # Should have enable button + source = self.get_page_source() + assert "Enable hls playback" in source + + def test_space_player_hls_enabled(self): + """With HLS enabled, should have audio element in DOM.""" + self.open(self.SPACE_URL) + # Set HLS preference via cookie + self.add_cookie({"name": "hlsPlayback", "value": "on"}) + self.refresh() + # Check page source for audio element (hidden until play clicked) + source = self.get_page_source() + assert '
" + if videoData.available: + let dlUrl = getVideoDownloadUrl(videoData) + if dlUrl.len > 0: + a(class="video-download", href=dlUrl, download="", + title="Download video"): icon "download-alt" proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode = let hasCardContent = video.description.len > 0 or video.title.len > 0 @@ -345,10 +356,10 @@ proc renderDisclosures*(tweet: Tweet): VNode = buildHtml(tdiv(class="disclosures")): if tweet.isAI: span(data-disclosure="ai"): - icon "attention", "Made with AI" + icon "attention-circled", "Made with AI" if tweet.isAd: span(data-disclosure="ad"): - icon "attention", "Paid partnership (ad)" + icon "attention-circled", "Paid partnership (ad)" proc renderLocation*(tweet: Tweet): string = let (place, url) = tweet.getLocation() From 4b3ad16cc85e76af17c643220d697c40b27e109e Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 26 Jun 2026 22:42:02 +0700 Subject: [PATCH 263/309] Revert "Exclude git from docker context (#1412)" (#1417) This reverts commit 446f06f9d67a09ef764082d96698b55e3ff51be0. --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index cbd5abe..9bf2fee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,3 @@ LICENSE docker-compose.yml Dockerfile tests/ -.git From 7f7092fbf50858c1f70a7f9109bf535cadfc11b5 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 1 Jul 2026 00:49:27 +0200 Subject: [PATCH 264/309] Support captions in articles Fixes #1418 --- src/experimental/parser/article.nim | 1 + src/experimental/types/article.nim | 1 + src/sass/_article.scss | 6 ++++++ src/types.nim | 1 + src/views/article.nim | 4 +++- src/views/general.nim | 2 +- tests/test_article.py | 32 ++++++++++++++++++++++------- 7 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/experimental/parser/article.nim b/src/experimental/parser/article.nim index 2ae7f4a..ae5de7c 100644 --- a/src/experimental/parser/article.nim +++ b/src/experimental/parser/article.nim @@ -61,6 +61,7 @@ proc parseGraphArticle*(json: string): Article = of "MEDIA": for mi in entry.value.data.mediaItems: entity.mediaIds.add mi.mediaId + entity.caption = entry.value.data.caption of "TWEET": entity.tweetId = entry.value.data.tweetId of "MARKDOWN": entity.markdown = entry.value.data.markdown else: discard diff --git a/src/experimental/types/article.nim b/src/experimental/types/article.nim index 024c69f..946f8c3 100644 --- a/src/experimental/types/article.nim +++ b/src/experimental/types/article.nim @@ -48,6 +48,7 @@ type mediaItems*: seq[tuple[mediaId: string]] tweetId*: string markdown*: string + caption*: string RawMediaEntity* = object mediaId*: string diff --git a/src/sass/_article.scss b/src/sass/_article.scss index 9cc728a..9488658 100644 --- a/src/sass/_article.scss +++ b/src/sass/_article.scss @@ -189,6 +189,12 @@ max-width: 100%; border-radius: 12px; } + + .article-media-caption { + color: var(--fg_faded); + font-size: 0.875rem; + margin-top: 6px; + } } > a, diff --git a/src/types.nim b/src/types.nim index 2ccad10..a490316 100644 --- a/src/types.nim +++ b/src/types.nim @@ -253,6 +253,7 @@ type mediaIds*: seq[string] tweetId*: string markdown*: string + caption*: string ArticleMedia* = object kind*: string diff --git a/src/views/article.nim b/src/views/article.nim index 1ba9c8b..eedd4cf 100644 --- a/src/views/article.nim +++ b/src/views/article.nim @@ -32,7 +32,9 @@ proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article; video(src=getVidUrl(media.url), controls="") else: a(href=getOrigPicUrl(media.url), target="_blank"): - img(src=getSmallPic(media.url), alt="", loading="lazy") + img(src=getSmallPic(media.url), alt=entity.caption, loading="lazy") + if entity.caption.len > 0: + p(class="article-media-caption"): text entity.caption of "TWEET": let tweet = tweets.getOrDefault( try: parseBiggestInt(entity.tweetId) diff --git a/src/views/general.nim b/src/views/general.nim index d49804e..37213c2 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=44") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=45") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/tests/test_article.py b/tests/test_article.py index 15a87c7..652ec76 100644 --- a/tests/test_article.py +++ b/tests/test_article.py @@ -14,6 +14,7 @@ class ArticleSelectors: avatar = '.article-author img.avatar' verified = '.article-author .verified-icon' media = '.article-media' + caption = '.article-media-caption' divider = '.article-divider' @@ -30,10 +31,6 @@ articles = [ 'Consciousness and AI: The Problem of Inner Experience', 'CosmicOrFun', 'Cosmic Orphan'], - ['2064696491948777658', - 'NC Push for Data Centers + Stablecoin Crypto= Data Centers are defacto BAILOUT OF Fed Reserve System', - 'June_12_1776', 'June_12_1776'], - ['2064755789391110154', 'DeFi Markets Update 2026-06-10', 'SteakhouseFi', 'Steakhouse Financial'], @@ -126,11 +123,11 @@ class ArticleContentTest(BaseTestCase): self.assertGreater(len(italic), 0) def test_article_has_blockquotes(self): - self.open_nitter('i/article/2064696491948777658') + self.open_nitter('i/article/2064166507438059759') self.assert_element_visible('.article-body blockquote') def test_article_has_lists(self): - self.open_nitter('i/article/2064696491948777658') + self.open_nitter('i/article/2064166507438059759') self.assert_element_visible('.article-body ul') def test_article_has_emoji_text(self): @@ -186,6 +183,27 @@ class ArticleMediaTest(BaseTestCase): tweets = self.find_elements('.article-body .timeline-item') self.assertGreaterEqual(len(tweets), 3) + def test_media_caption_displayed(self): + self.open_nitter('i/article/2064689664213041529') + self.assert_element_visible(ArticleSelectors.caption) + captions = self.find_elements(ArticleSelectors.caption) + self.assertGreaterEqual(len(captions), 5) + + def test_media_caption_text(self): + self.open_nitter('i/article/2064689664213041529') + self.assert_text_visible('FIGURE 1', ArticleSelectors.caption) + + def test_media_caption_alt_attribute(self): + self.open_nitter('i/article/2064689664213041529') + img = self.find_element(f'{ArticleSelectors.media} img') + alt = img.get_attribute('alt') + self.assertGreater(len(alt), 0) + + def test_no_caption_when_absent(self): + self.open_nitter('i/article/2062858677149675788') + captions = self.find_elements(ArticleSelectors.caption) + self.assertEqual(len(captions), 0) + class ArticleMentionTest(BaseTestCase): def test_mention_linkified(self): @@ -209,7 +227,7 @@ class ArticleMentionTest(BaseTestCase): def test_no_spurious_whitespace_in_styled_paragraph(self): """Styled paragraphs should not have extra whitespace from VNode serialization.""" - self.open_nitter('i/article/2064696491948777658') + self.open_nitter('i/article/2064166507438059759') source = self.get_page_source() self.assertNotIn('white-space: pre-wrap', source) self.assertNotIn('white-space:pre-wrap', source) From 2f07fa6151bf46f8fe704afcd0ee97d71c83dd7b Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jul 2026 00:11:52 +0200 Subject: [PATCH 265/309] Sort replies by relevance, recency, or likes Fixes #1336 --- src/api.nim | 18 +++++++++--------- src/consts.nim | 1 + src/routes/status.nim | 12 +++++++----- src/sass/tweet/thread.scss | 33 +++++++++++++++++++++++++++++++++ src/types.nim | 3 +++ src/views/general.nim | 2 +- src/views/status.nim | 26 ++++++++++++++++++++++---- src/views/timeline.nim | 4 ++-- tests/base.py | 2 ++ tests/test_reply_sort.py | 37 +++++++++++++++++++++++++++++++++++++ 10 files changed, 117 insertions(+), 21 deletions(-) create mode 100644 tests/test_reply_sort.py diff --git a/src/api.nim b/src/api.nim index f73ef10..23fbcec 100644 --- a/src/api.nim +++ b/src/api.nim @@ -35,8 +35,8 @@ proc userTweetsUrl(id: string; cursor: string): ApiReq = proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true) -proc tweetDetailUrl(id: string; cursor: string): ApiReq = - return apiReq(graphTweet, tweetVars % [id, cursor]) +proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq = + return apiReq(graphTweet, tweetVars % [id, cursor, $mode]) # let cookieVars = tweetDetailVars % [id, cursor] # result = ApiReq( # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), @@ -230,21 +230,21 @@ proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = js = await fetch(url) result = parseGraphTweetResult(js) -proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = +proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = if id.len == 0: return let cursor = cursorParam(after) - js = await fetch(tweetDetailUrl(id, cursor)) + js = await fetch(tweetDetailUrl(id, cursor, mode)) result = parseGraphConversation(js, id) -proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = - result = (await getGraphTweet(id, after)).replies +proc getReplies*(id, after: string; mode = Relevance): Future[Result[Chain]] {.async.} = + result = (await getGraphTweet(id, after, mode)).replies result.beginning = after.len == 0 -proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = - result = await getGraphTweet(id) +proc getTweet*(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = + result = await getGraphTweet(id, mode=mode) if after.len > 0: - result.replies = await getReplies(id, after) + result.replies = await getReplies(id, after, mode) proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} = if id.len == 0: return diff --git a/src/consts.nim b/src/consts.nim index 899a06c..afef54d 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -92,6 +92,7 @@ const tweetVars* = """{ "postId": "$1", $2 + "ranking_mode": "$3", "includeHasBirdwatchNotes": false, "includePromotedContent": false, "withBirdwatchNotes": true, diff --git a/src/routes/status.nim b/src/routes/status.nim index f7fb1bb..870b6d7 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -21,16 +21,18 @@ proc createStatusRouter*(cfg: Config) = if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) - let prefs = requestPrefs() + let + prefs = requestPrefs() + sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance) # used for the infinite scroll feature if @"scroll".len > 0: - let replies = await getReplies(id, getCursor()) + let replies = await getReplies(id, getCursor(), sort) if replies.content.len == 0: resp Http204 - resp $renderReplies(replies, prefs, getPath()) + resp $renderReplies(replies, prefs, getPath(), sort=sort) - let conv = await getTweet(id, getCursor()) + let conv = await getTweet(id, getCursor(), sort) if conv == nil or conv.tweet == nil or conv.tweet.id == 0: var error = "Tweet not found" @@ -64,7 +66,7 @@ proc createStatusRouter*(cfg: Config) = elif card.video.isSome(): images = @[card.video.get().thumb] - let html = renderConversation(conv, prefs, getPath() & "#m") + let html = renderConversation(conv, prefs, getPath() & "#m", sort) resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, images=images, video=video) diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index c5165d7..c7b50ba 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -19,6 +19,39 @@ margin-bottom: 10px; } +.reply-sort { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px 14px; + margin-bottom: 10px; + padding: 8px 12px; + background-color: var(--bg_panel); + font-size: 14px; +} + +.reply-sort-label { + color: var(--fg_faded); + margin-right: 2px; +} + +.reply-sort-option { + color: var(--tab); + font-weight: bold; + text-decoration: none; + border-bottom: 0.1rem solid transparent; + + &:hover { + color: var(--fg_color); + text-decoration: none; + } + + &.active { + color: var(--tab_selected); + border-bottom-color: var(--tab_selected); + } +} + .main-tweet, .replies, .edit-history > div { diff --git a/src/types.nim b/src/types.nim index a490316..5fac273 100644 --- a/src/types.nim +++ b/src/types.nim @@ -176,6 +176,9 @@ type QueryKind* = enum posts, replies, media, users, tweets, userList, followers, following + RankingMode* = enum + Relevance, Recency, Likes + Query* = object kind*: QueryKind view*: string diff --git a/src/views/general.nim b/src/views/general.nim index 37213c2..e6203c7 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=45") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=46") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/src/views/status.nim b/src/views/status.nim index fdb4ae9..39af8fa 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -28,7 +28,22 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = if thread.hasMore: renderMoreReplies(thread) -proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode = +proc renderReplySort(sort: RankingMode): VNode = + buildHtml(tdiv(class="reply-sort")): + span(class="reply-sort-label"): text "Sort replies:" + for mode in RankingMode: + let + cls = if mode == sort: "reply-sort-option active" + else: "reply-sort-option" + label = case mode + of Relevance: "Relevant" + of Recency: "Recent" + of Likes: "Liked" + a(class=cls, href=("?sort=" & $mode & "#r")): + text label + +proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; + tweet: Tweet = nil; sort = Relevance): VNode = buildHtml(tdiv(class="replies", id="r")): var hasReplies = false var replyCount = 0 @@ -40,9 +55,11 @@ proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: T if hasReplies and replies.bottom.len > 0: if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies: - renderMore(Query(), replies.bottom, focus="#r") + let extra = if sort == Relevance: "" else: "sort=" & $sort & "&" + renderMore(Query(), replies.bottom, focus="#r", extra=extra) -proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = +proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; + sort = Relevance): VNode = let hasAfter = conv.after.content.len > 0 let threadId = conv.tweet.threadId buildHtml(tdiv(class="conversation")): @@ -75,7 +92,8 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode if not conv.replies.beginning: renderNewer(Query(), getLink(conv.tweet), focus="#r") if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: - renderReplies(conv.replies, prefs, path, conv.tweet) + renderReplySort(sort) + renderReplies(conv.replies, prefs, path, conv.tweet, sort) renderToTop(focus="#m") diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 9456bf9..0e45dc2 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -50,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode = a(href=(p & url)): text "Load newest" -proc renderMore*(query: Query; cursor: string; focus=""): VNode = +proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode = buildHtml(tdiv(class="show-more")): - a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): + a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): text "Load more" proc renderNoMore(): VNode = diff --git a/tests/base.py b/tests/base.py index 6d795e1..f5e0056 100644 --- a/tests/base.py +++ b/tests/base.py @@ -71,6 +71,8 @@ class Conversation(object): thread = '.reply' tweet = '.timeline-item' tweet_text = '.tweet-content' + reply_sort = '.reply-sort' + reply_sort_active = '.reply-sort-option.active' class Poll(object): diff --git a/tests/test_reply_sort.py b/tests/test_reply_sort.py new file mode 100644 index 0000000..1d89e5d --- /dev/null +++ b/tests/test_reply_sort.py @@ -0,0 +1,37 @@ +from parameterized import parameterized + +from base import BaseTestCase, Conversation + +sort_modes = [ + ['jack/status/20', 'Relevant'], + ['jack/status/20?sort=relevance', 'Relevant'], + ['jack/status/20?sort=recency', 'Recent'], + ['jack/status/20?sort=likes', 'Liked'], + ['jack/status/20?sort=garbage', 'Relevant'], + ['jack/status/20?sort=%3Cscript%3E', 'Relevant'], +] + + +class ReplySortTest(BaseTestCase): + @parameterized.expand(sort_modes) + def test_active_mode(self, page, expected_active): + self.open_nitter(page) + self.assert_element_visible(Conversation.reply_sort) + active = self.get_text(Conversation.reply_sort_active) + self.assert_equal(active.strip(), expected_active) + + def test_all_three_options_present(self): + self.open_nitter('jack/status/20') + options = self.find_elements('.reply-sort-option') + labels = [o.text.strip() for o in options] + self.assert_equal(labels, ['Relevant', 'Recent', 'Liked']) + + def test_option_links_carry_sort_param(self): + self.open_nitter('jack/status/20') + for slug in ['Relevance', 'Recency', 'Likes']: + self.assert_element(f'.reply-sort-option[href="?sort={slug}#r"]') + + def test_load_more_preserves_sort(self): + self.open_nitter('jack/status/20?sort=Likes') + href = self.get_attribute('.replies .show-more a', 'href') + self.assert_true('sort=Likes' in href, f'sort missing from: {href}') From 6b86aaa27ffcedc0b780772495b56b9157a6fde4 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jul 2026 00:11:52 +0200 Subject: [PATCH 266/309] Triple pytest timeout multiplier for cold loads --- tests/pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/pyproject.toml b/tests/pyproject.toml index ade5000..323f114 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -6,3 +6,6 @@ package-mode = false [tool.poetry.dependencies] python = "^3.14" seleniumbase = "4.46.5" + +[tool.pytest.ini_options] +addopts = "--timeout_multiplier=3" From 0375145ea28d38b32e411834b14018913462abd4 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jul 2026 01:55:25 +0200 Subject: [PATCH 267/309] Fix crash when TID pair fetch fails --- src/tid.nim | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tid.nim b/src/tid.nim index 7b453fb..ba1f8ec 100644 --- a/src/tid.nim +++ b/src/tid.nim @@ -16,14 +16,16 @@ var proc getPair(): Future[TidPair] {.async.} = if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec: - lastCached = int(epochTime()) - let client = newAsyncHttpClient() defer: client.close() let resp = await client.get(pairsUrl) if resp.status == $Http200: cachedPairs = parseTidPairs(await resp.body) + lastCached = int(epochTime()) + + if cachedPairs.len == 0: + raise newException(ValueError, "Failed to fetch x-client-transaction-id pairs") return sample(cachedPairs) From 61246df9de96f9338dfbc8424f89f7f7bb26800f Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 8 Jul 2026 18:56:12 +0200 Subject: [PATCH 268/309] Support Top, Latest, Media, and Lists search Fixes #1335 --- src/api.nim | 32 +++++++--- src/parser.nim | 102 +++++++++++++++++++------------ src/parserutils.nim | 6 +- src/query.nim | 4 +- src/routes/rss.nim | 2 +- src/routes/search.nim | 20 ++++++- src/sass/search.scss | 74 +++++++++++++++++++++++ src/sass/timeline.scss | 20 +++++++ src/types.nim | 8 ++- src/views/general.nim | 2 +- src/views/search.nim | 62 +++++++++++++++---- src/views/timeline.nim | 78 ++++++++++++++++++++++++ tests/base.py | 11 +++- tests/test_search.py | 132 +++++++++++++++++++++++++++++++++++++++-- 14 files changed, 475 insertions(+), 78 deletions(-) diff --git a/src/api.nim b/src/api.nim index 23fbcec..041a492 100644 --- a/src/api.nim +++ b/src/api.nim @@ -263,12 +263,19 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = if q.len == 0 or q == emptyQuery: return Timeline(query: query, beginning: true) + let product = + case query.kind + of top: "Top" + # profile media feeds (RSS, multi-user timelines) must stay chronological + of media: (if query.fromUser.len == 0: "Media" else: "Latest") + else: "Latest" + var variables = %*{ "rawQuery": q, "count": 20, "querySource": "typed_query", - "product": "Latest", + "product": product, "withGrokTranslatedBio":true, "withQuickPromoteEligibilityTweetFields":false } @@ -283,33 +290,40 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = # when no more items are available the API just returns the last page in # full. this detects that and clears the page instead. - if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and - after[0..<64] == result.bottom[0..<64]: + let prefix = min(64, min(after.len, result.bottom.len)) + if prefix > 0 and maxId.len == 0 and + after[0.. 0: variables["cursor"] = % after - result.beginning = false - let + let url = apiReq(graphSearchTimeline, $variables) js = await fetch(url) - result = parseGraphSearch[User](js, after) + result = parseGraphSearch[T](js, after) result.query = query +proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] = + getGraphProductSearch[User](query, "People", after) + +proc getGraphListSearch*(query: Query; after=""): Future[Result[ListSearchResult]] = + getGraphProductSearch[ListSearchResult](query, "Lists", after) + proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return let js = await fetch(mediaUrl(id, "", 30)) diff --git a/src/parser.nim b/src/parser.nim index 04a7d33..30f70d2 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -206,6 +206,20 @@ proc parseGraphCommunity*(js: JsonNode): Community = if tag.len > 0: result.hashtags.add tag +proc parseListObject(js: JsonNode; owner: User): List = + List( + id: js{"id_str"}.getStr, + name: js{"name"}.getStr, + username: owner.username, + userId: owner.id, + description: js{"description"}.getStr, + members: js{"member_count"}.getInt, + banner: select( + js{"custom_banner_media", "media_info", "original_img_url"}, + js{"default_banner_media", "media_info", "original_img_url"} + ).getImageStr + ) + proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -215,15 +229,17 @@ proc parseGraphList*(js: JsonNode): List = if list.isNull: return - result = List( - id: list{"id_str"}.getStr, - name: list{"name"}.getStr, - username: list{"user_results", "result", "legacy", "screen_name"}.getStr, - userId: list{"user_results", "result", "rest_id"}.getStr, - description: list{"description"}.getStr, - members: list{"member_count"}.getInt, - banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr + result = parseListObject(list, parseGraphUser(list)) + +proc parseGraphSearchList(js: JsonNode): ListSearchResult = + let owner = parseGraphUser(js) + result = ListSearchResult( + list: parseListObject(js, owner), + owner: owner, + followersContext: js{"followers_context"}.getStr ) + for url in js{"facepile_urls"}: + result.facepiles.add url.getStr proc parsePoll(js: JsonNode): Poll = let vals = js{"binding_values"} @@ -808,20 +824,31 @@ proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory = if tweetResult.notNull: result.history.add parseGraphTweet(tweetResult) +iterator extractTweetsFromModuleItems(items: JsonNode): Tweet = + for item in items: + with tweetResult, item.getTweetResult("item"): + let tweet = parseGraphTweet(tweetResult) + if not tweet.available: + tweet.id = item.getEntryId.getId + yield tweet + +iterator extractListsFromItems(items: JsonNode): ListSearchResult = + for item in items: + with listJs, item{"item", "itemContent", "list"}: + let r = parseGraphSearchList(listJs) + if r.list.id.len > 0: + yield r + proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = with tweetResult, getTweetResult(e): - var tweet = parseGraphTweet(tweetResult) + let tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = e.getEntryId.getId result.add tweet return - for item in e{"content", "items"}: - with tweetResult, item.getTweetResult("item"): - var tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = item.getEntryId.getId - result.add tweet + for tweet in extractTweetsFromModuleItems(e{"content", "items"}): + result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) @@ -836,12 +863,8 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = for i in instructions: if i{"moduleItems"}.notNull: - for item in i{"moduleItems"}: - with tweetResult, item.getTweetResult("item"): - let tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = item.getEntryId.getId - result.tweets.content.add tweet + for tweet in extractTweetsFromModuleItems(i{"moduleItems"}): + result.tweets.content.add tweet continue if i{"entries"}.notNull: @@ -876,18 +899,13 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = for i in instructions: if i{"moduleItems"}.notNull: - for item in i{"moduleItems"}: - with tweetResult, item.getTweetResult("item"): - let t = parseGraphTweet(tweetResult) - if not t.available: - t.id = item.getEntryId.getId + for t in extractTweetsFromModuleItems(i{"moduleItems"}): + let photo = extractGalleryPhoto(t) + if photo.url.len > 0: + result.add photo - let photo = extractGalleryPhoto(t) - if photo.url.len > 0: - result.add photo - - if result.len == 16: - return + if result.len == 16: + return continue if i.getTypeName != "TimelineAddEntries": @@ -904,7 +922,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = if result.len == 16: return -proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = +proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) let instructions = select( @@ -920,19 +938,27 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = for e in instruction{"entries"}: let entryId = e.getEntryId when T is Tweets: - if entryId.startsWith("tweet"): - with tweetRes, getTweetResult(e): - let tweet = parseGraphTweet(tweetRes) - if not tweet.available: - tweet.id = entryId.getId + if entryId.startsWith("tweet") or entryId.startsWith("search-grid"): + for tweet in extractTweetsFromEntry(e): result.content.add tweet elif T is User: if entryId.startsWith("user"): with userRes, e{"content", "itemContent"}: result.content.add parseGraphUser(userRes) + elif T is ListSearchResult: + if entryId.startsWith("list-search"): + for list in extractListsFromItems(e{"content", "items"}): + result.content.add list if entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr + elif typ == "TimelineAddToModule": + when T is Tweets: + for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}): + result.content.add tweet + elif T is ListSearchResult: + for list in extractListsFromItems(instruction{"moduleItems"}): + result.content.add list elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): result.bottom = instruction{"entry", "content", "value"}.getStr diff --git a/src/parserutils.nim b/src/parserutils.nim index 07aa088..8db96a5 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -98,9 +98,9 @@ proc getTimeFromMsStr*(js: JsonNode): DateTime = proc getId*(id: string): int64 {.inline.} = let start = id.rfind("-") - if start < 0: - return parseBiggestInt(id) - return parseBiggestInt(id[start + 1 ..< id.len]) + try: + parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len]) + except ValueError: 0'i64 proc getId*(js: JsonNode): int64 {.inline.} = case js.kind diff --git a/src/query.nim b/src/query.nim index 38fe6f4..f541d27 100644 --- a/src/query.nim +++ b/src/query.nim @@ -104,7 +104,9 @@ proc genQueryUrl*(query: Query): string = if query.view.len > 0: params.add "view=" & encodeUrl(query.view) - if query.kind in {tweets, users}: + # media doubles as the profile media tab, where f isn't part of the URL scheme + if query.kind in {tweets, users, lists, top} or + (query.kind == media and query.fromUser.len == 0): params.add &"f={query.kind}" if query.text.len > 0: params.add "q=" & encodeUrl(query.text) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 444dc70..f2582c1 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -68,7 +68,7 @@ proc createRssRouter*(cfg: Config) = let prefs = requestPrefs() query = initQuery(params(request)) - if query.kind != tweets: + if query.kind notin {tweets, top, media}: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) let diff --git a/src/routes/search.nim b/src/routes/search.nim index ba023fd..7c7fd14 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -20,9 +20,21 @@ proc createSearchRouter*(cfg: Config) = let prefs = requestPrefs() - query = initQuery(params(request)) title = "Search" & (if q.len > 0: " (" & q & ")" else: "") + var query = initQuery(params(request)) + # x.com URL compat: f=user and f=list map to our kind names + # (f=live already falls back to tweets/Latest; f=media matches natively) + if @"f" == "user": + query.kind = users + elif @"f" == "list": + query.kind = lists + + # media searches support view modes, defaulting like /user/media + if query.kind == QueryKind.media and + query.view notin ["timeline", "grid", "gallery"]: + query.view = prefs.mediaView.toLowerAscii + case query.kind of users: if "," in q: @@ -33,12 +45,16 @@ proc createSearchRouter*(cfg: Config) = except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) - of tweets: + of tweets, top, QueryKind.media: let tweets = await getGraphTweetSearch(query, getCursor()) rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: "" resp renderMain(renderTweetSearch(tweets, prefs, getPath()), request, cfg, prefs, title, rss=rss) + of lists: + let listResults = await getGraphListSearch(query, getCursor()) + resp renderMain(renderListSearch(listResults, prefs, getPath()), + request, cfg, prefs, title) else: resp Http404, showError("Invalid search", cfg) diff --git a/src/sass/search.scss b/src/sass/search.scss index c2adaf7..fa2a2d8 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -107,6 +107,80 @@ grid-column-gap: 10px; } +.list-result { + display: flex; + align-items: flex-start; + + .list-result-banner { + flex-shrink: 0; + width: 56px; + height: 56px; + margin-right: 10px; + border-radius: 8px; + overflow: hidden; + background-color: var(--darker_grey); + // stay above the tweet-link overlay's hover background + z-index: 1; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .list-result-body { + min-width: 0; + pointer-events: none; + z-index: 1; + + a { + pointer-events: all; + } + } + + .list-result-title { + align-items: baseline; + } + + .list-members { + flex-shrink: 0; + margin-left: 0.3em; + color: var(--fg_faded); + } + + .list-result-context { + display: flex; + align-items: center; + flex-wrap: wrap; + margin-top: 2px; + color: var(--fg_faded); + + a { + color: var(--fg_dark); + } + + a.fullname { + color: var(--fg_color); + } + + .list-facepile { + width: 20px; + height: 20px; + border-radius: 50%; + margin-right: 4px; + } + } + + .list-result-description { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: all; + } +} + .profile-tabs { @include search-resize(820px, 5); @include search-resize(715px, 4); diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index e167448..b7d4a9f 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -4,6 +4,26 @@ @include panel(100%, 600px); } +.timeline-container.media-only { + max-width: none; + width: 100%; + padding: 0 10px; + box-sizing: border-box; + + > .tab, + > .timeline-header { + max-width: 900px; + margin-left: auto; + margin-right: auto; + } +} + +@media (max-width: 700px) { + .timeline-container.media-only { + padding: 0; + } +} + .timeline > div:not(:first-child) { border-top: 1px solid var(--border_grey); } diff --git a/src/types.nim b/src/types.nim index 5fac273..d19a10d 100644 --- a/src/types.nim +++ b/src/types.nim @@ -174,7 +174,7 @@ type variants*: seq[VideoVariant] QueryKind* = enum - posts, replies, media, users, tweets, userList, followers, following + posts, replies, media, users, tweets, userList, followers, following, lists, top RankingMode* = enum Relevance, Recency, Likes @@ -388,6 +388,12 @@ type members*: int banner*: string + ListSearchResult* = object + list*: List + owner*: User + followersContext*: string + facepiles*: seq[string] + CommunityRule* = object name*: string description*: string diff --git a/src/views/general.nim b/src/views/general.nim index e6203c7..afdda9f 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=46") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=50") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/src/views/search.nim b/src/views/search.nim index c79f3fe..fe50280 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -39,28 +39,45 @@ proc renderProfileTabs*(query: Query; username: string): VNode = li(class=query.getTabClass(tweets)): a(href=(link & "/search")): text "Search" -proc renderMediaViewTabs*(query: Query; username: string): VNode = +proc mediaViewUrl(query: Query; view: string): string = + var q = query + q.view = view + "?" & genQueryUrl(q) + +proc renderMediaViewTabs*(query: Query): VNode = let currentView = if query.view.len > 0: query.view else: "timeline" - let base = "/" & username & "/media?view=" func cls(view: string): string = if currentView == view: "tab-item active" else: "tab-item" buildHtml(ul(class="tab media-view-tabs")): li(class=cls("timeline")): - a(href=(base & "timeline")): text "Timeline" + a(href=query.mediaViewUrl("timeline")): text "Timeline" li(class=cls("grid")): - a(href=(base & "grid")): text "Grid" + a(href=query.mediaViewUrl("grid")): text "Grid" li(class=cls("gallery")): - a(href=(base & "gallery")): text "Gallery" + a(href=query.mediaViewUrl("gallery")): text "Gallery" proc renderSearchTabs*(query: Query): VNode = var q = query + # the media view mode only applies to the Media tab + q.view = "" buildHtml(ul(class="tab")): + li(class=query.getTabClass(top)): + q.kind = top + a(href=("?" & genQueryUrl(q))): text "Top" li(class=query.getTabClass(tweets)): q.kind = tweets - a(href=("?" & genQueryUrl(q))): text "Tweets" + a(href=("?" & genQueryUrl(q))): text "Latest" + li(class=query.getTabClass(media)): + q.kind = media + q.view = query.view + a(href=("?" & genQueryUrl(q))): text "Media" li(class=query.getTabClass(users)): q.kind = users + q.view = "" a(href=("?" & genQueryUrl(q))): text "Users" + li(class=query.getTabClass(lists)): + q.kind = lists + a(href=("?" & genQueryUrl(q))): text "Lists" proc isPanelOpen(q: Query): bool = q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or @@ -71,7 +88,7 @@ proc renderSearchPanel*(query: Query): VNode = let action = if user.len > 0: &"/{user}/search" else: "/search" buildHtml(form(`method`="get", action=action, class="search-field", autocomplete="off")): - hiddenField("f", "tweets") + hiddenField("f", $query.kind) genInput("q", "", query.text, "Enter search...", class="pref-inline") button(`type`="submit"): icon "search" @@ -102,7 +119,11 @@ proc renderSearchPanel*(query: Query): VNode = proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = let query = results.query - buildHtml(tdiv(class="timeline-container")): + let containerClass = + if query.fromUser.len == 0 and query.kind == media and + query.view == "gallery": "timeline-container media-only" + else: "timeline-container" + buildHtml(tdiv(class=containerClass)): if query.fromUser.len > 1: tdiv(class="timeline-header"): text query.fromUser.join(" | ") @@ -111,7 +132,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; if query.kind != media or query.view != "gallery": renderProfileTabs(query, query.fromUser.join(",")) if query.kind == media and query.fromUser.len == 1: - renderMediaViewTabs(query, query.fromUser[0]) + renderMediaViewTabs(query) if query.fromUser.len == 0 or query.kind == tweets: tdiv(class="timeline-header"): @@ -119,16 +140,31 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; if query.fromUser.len == 0: renderSearchTabs(query) + if query.kind == media: + renderMediaViewTabs(query) renderTimelineTweets(results, prefs, path, pinned) +proc renderSearchForm(kind, placeholder, value: string): VNode = + buildHtml(form(`method`="get", action="/search", + class="search-field", autocomplete="off")): + hiddenField("f", kind) + genInput("q", "", value, placeholder, class="pref-inline") + button(`type`="submit"): icon "search" + proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-container")): tdiv(class="timeline-header"): - form(`method`="get", action="/search", class="search-field", autocomplete="off"): - hiddenField("f", "users") - genInput("q", "", results.query.text, "Enter username...", class="pref-inline") - button(`type`="submit"): icon "search" + renderSearchForm("users", "Enter username...", results.query.text) renderSearchTabs(results.query) renderTimelineUsers(results, prefs) + +proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs; + path: string): VNode = + buildHtml(tdiv(class="timeline-container")): + tdiv(class="timeline-header"): + renderSearchForm("lists", "Enter search...", results.query.text) + + renderSearchTabs(results.query) + renderTimelineLists(results, prefs, path) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 0e45dc2..9c85c9c 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -114,6 +114,84 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = else: renderNoMore() +proc mentionUsername(word: string): string = + # "@user" -> "user" for well-formed mentions, "" otherwise + if word.len > 1 and word[0] == '@' and + word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}): + word[1 .. ^1] + else: "" + +proc mentionedUser(s: string): string = + # last @mention in strings like "65 followers including @user" + let words = s.split(' ') + for i in countdown(words.high, 0): + result = mentionUsername(words[i]) + if result.len > 0: return + +proc renderMentionedText(s: string): VNode = + # linkify @mentions in plain API strings like "65 followers including @user" + let words = s.split(' ') + buildHtml(span): + for i in 0 ..< words.len: + if i > 0: text " " + let username = mentionUsername(words[i]) + if username.len > 0: + a(href=("/" & username)): text words[i] + else: + text words[i] + +proc renderListCard(r: ListSearchResult): VNode = + let listUrl = "/i/lists/" & r.list.id + buildHtml(tdiv(class="timeline-item list-result")): + a(class="tweet-link", href=listUrl) + a(class="list-result-banner", href=listUrl): + if r.list.banner.len > 0: + genImg(r.list.banner) + tdiv(class="list-result-body"): + tdiv(class="list-result-title fullname-and-username"): + a(class="list-name fullname", href=listUrl): text r.list.name + span(class="list-members"): + text &"· {insertSep($r.list.members, ',')} members" + tdiv(class="list-result-context"): + if r.followersContext.len > 0: + # the first facepile belongs to the "including @user" account + let mentioned = mentionedUser(r.followersContext) + for i in 0 ..< r.facepiles.len: + if i == 0 and mentioned.len > 0: + a(class="facepile-link", href=("/" & mentioned)): + genImg(r.facepiles[i], class="list-facepile") + else: + genImg(r.facepiles[i], class="list-facepile") + renderMentionedText(r.followersContext) + else: + if r.owner.username.len > 0: + a(class="facepile-link", href=("/" & r.owner.username)): + genImg(r.owner.getUserPic("_mini"), class="list-facepile") + else: + genImg(r.owner.getUserPic("_mini"), class="list-facepile") + linkUser(r.owner, class="fullname") + linkUser(r.owner, class="username") + if r.list.description.len > 0: + tdiv(class="list-result-description"): + text r.list.description + +proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs; + path=""): VNode = + buildHtml(tdiv(class="timeline")): + if not results.beginning: + renderNewer(results.query, path) + + if results.content.len > 0: + for list in results.content: + renderListCard(list) + if results.bottom.len > 0: + renderMore(results.query, results.bottom) + renderToTop() + elif results.beginning: + renderNoneFound() + else: + renderNoMore() + proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] = var retweets: seq[int64] for thread in threads: diff --git a/tests/base.py b/tests/base.py index f5e0056..841094d 100644 --- a/tests/base.py +++ b/tests/base.py @@ -55,14 +55,19 @@ class Timeline(object): protected = '.timeline-protected' photo_rail = '.photo-rail-grid' media_view_tabs = '.media-view-tabs' - media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]' - media_view_grid = '.media-view-tabs a[href$="media?view=grid"]' - media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]' + media_view_timeline = '.media-view-tabs a[href*="view=timeline"]' + media_view_grid = '.media-view-tabs a[href*="view=grid"]' + media_view_gallery = '.media-view-tabs a[href*="view=gallery"]' media_view_active = '.media-view-tabs .tab-item.active a' grid_view = '.timeline.media-grid-view' gallery_view = '.timeline.media-gallery-view' +class Search(object): + tab_item = '.tab .tab-item' + tab_active = '.tab .tab-item.active a' + + class Conversation(object): main = '.main-tweet' before = '.before-tweet' diff --git a/tests/test_search.py b/tests/test_search.py index 62c4640..0f5456f 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,9 +1,129 @@ -from base import BaseTestCase from parameterized import parameterized +from base import BaseTestCase, Search -#class SearchTest(BaseTestCase): - #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) - #def test_username_search(self, username): - #self.search_username(username) - #self.assert_text(f'{username}') +# [url, expected active tab label] +active_tabs = [ + ['search?f=tweets&q=nasa', 'Latest'], + ['search?f=top&q=nasa', 'Top'], + ['search?f=media&q=nasa', 'Media'], + ['search?f=users&q=nasa', 'Users'], + ['search?f=lists&q=test', 'Lists'], + # unknown/hostile values fall back to Latest + ['search?f=garbage&q=nasa', 'Latest'], + ['search?f=%3Cscript%3E&q=nasa', 'Latest'], + # x.com URL compat: f=live/user/list (f=media/top match natively) + ['search?f=live&q=nasa', 'Latest'], + ['search?f=user&q=nasa', 'Users'], + ['search?f=list&q=test', 'Lists'], +] + +results_pages = [ + ['search?f=tweets&q=nasa'], + ['search?f=top&q=nasa'], + ['search?f=media&q=nasa'], +] + + +class SearchProductTest(BaseTestCase): + @parameterized.expand(active_tabs) + def test_active_tab(self, page, expected_active): + self.open_nitter(page) + active = self.get_text(Search.tab_active) + self.assert_equal(active.strip(), expected_active) + + def test_all_tabs_present(self): + self.open_nitter('search?f=tweets&q=nasa') + tabs = self.find_elements(Search.tab_item) + labels = [t.text.strip() for t in tabs] + self.assert_equal(labels, ['Top', 'Latest', 'Media', 'Users', 'Lists']) + + @parameterized.expand(results_pages) + def test_results_render(self, page): + self.open_nitter(page) + self.assert_element('.timeline .timeline-item') + + def test_tab_links_carry_kind(self): + self.open_nitter('search?f=tweets&q=nasa') + self.assert_element('.tab-item a[href="?f=top&q=nasa"]') + self.assert_element('.tab-item a[href="?f=media&q=nasa"]') + self.assert_element('.tab-item a[href="?f=tweets&q=nasa"]') + self.assert_element('.tab-item a[href="?f=users&q=nasa"]') + self.assert_element('.tab-item a[href="?f=lists&q=nasa"]') + + def test_show_more_preserves_kind(self): + self.open_nitter('search?f=media&q=nasa') + href = self.get_attribute('.show-more a', 'href') + self.assert_true('f=media' in href, f'f=media missing from: {href}') + + def test_search_form_preserves_kind(self): + self.open_nitter('search?f=top&q=nasa') + self.assert_element_present('.search-field input[name="f"][value="top"]') + + def test_media_operators_compose(self): + self.open_nitter('search?f=media&q=nasa&e-nativeretweets=on') + self.assert_element('.timeline .timeline-item') + + @parameterized.expand([['DAAC'], ['AB'], ['maxid:'], ['maxid:abc']]) + def test_garbage_cursor_no_crash(self, cursor): + # short/invalid cursors must render the page, not a 500 error + self.open_nitter(f'search?f=media&q=nasa&cursor={cursor}') + self.assert_element(Search.tab_active) + + def test_no_results(self): + self.open_nitter('search?f=media&q=xkqzjwv_no_results_2026') + self.assert_text('No items found', '.timeline-none') + + def test_list_results_render(self): + self.open_nitter('search?f=lists&q=test') + self.assert_element('.timeline-item.list-result') + self.assert_element('.list-result .list-name') + self.assert_element('.list-result .list-members') + + def test_list_card_links_to_list(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.list-result .list-name', 'href') + self.assert_true('/i/lists/' in href, f'unexpected list link: {href}') + + def test_list_row_clickable(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.list-result a.tweet-link', 'href') + self.assert_true('/i/lists/' in href, f'unexpected row link: {href}') + + def test_list_avatar_links_to_user(self): + self.open_nitter('search?f=lists&q=test') + # the avatar link in a row must point at the user named in that row + row = '.list-result:has(a.facepile-link)' + self.assert_element(f'{row} a.facepile-link > img') + href = self.get_attribute(f'{row} a.facepile-link', 'href') + ctx = self.get_text(f'{row} .list-result-context') + mentioned = ctx.split('@')[-1].strip() + self.assert_true(href.endswith('/' + mentioned), + f'avatar link {href} does not match @{mentioned}') + + def test_list_pagination_preserves_kind(self): + self.open_nitter('search?f=lists&q=test') + href = self.get_attribute('.show-more a', 'href') + self.assert_true('f=lists' in href, f'f=lists missing from: {href}') + + def test_list_garbage_cursor_no_crash(self): + self.open_nitter('search?f=lists&q=test&cursor=DAAC') + self.assert_element(Search.tab_active) + + def test_media_view_tabs_present(self): + self.open_nitter('search?f=media&q=nasa') + tabs = self.find_elements('.media-view-tabs .tab-item') + labels = [t.text.strip() for t in tabs] + self.assert_equal(labels, ['Timeline', 'Grid', 'Gallery']) + + def test_media_view_grid(self): + self.open_nitter('search?f=media&q=nasa&view=grid') + self.assert_element('.timeline.media-grid-view') + + def test_media_view_gallery(self): + self.open_nitter('search?f=media&q=nasa&view=gallery') + self.assert_element('.timeline.media-gallery-view .gallery-masonry') + + def test_media_view_tabs_only_on_media(self): + self.open_nitter('search?f=tweets&q=nasa') + self.assert_element_not_present('.media-view-tabs') From 8394b044ef8aabc974e055de40a590c04f32dded Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 9 Jul 2026 11:32:31 +0200 Subject: [PATCH 269/309] Qualify QueryKind.media to fix Nim 2.0.x build Fixes #1422 --- src/query.nim | 6 +++--- src/routes/rss.nim | 2 +- src/routes/timeline.nim | 2 +- src/views/profile.nim | 2 +- src/views/search.nim | 10 +++++----- src/views/timeline.nim | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/query.nim b/src/query.nim index f541d27..aeda0a1 100644 --- a/src/query.nim +++ b/src/query.nim @@ -34,7 +34,7 @@ proc initQuery*(pms: Table[string, string]; name=""): Query = proc getMediaQuery*(name: string): Query = Query( - kind: media, + kind: QueryKind.media, filters: @["twimg", "native_video"], fromUser: @[name], sep: "OR" @@ -64,7 +64,7 @@ proc genQueryParam*(query: Query; maxId=""): string = else: param &= ")" - if query.fromUser.len > 0 and query.kind in {posts, media}: + if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}: param &= " (filter:self_threads OR -filter:replies)" if "nativeretweets" notin query.excludes: @@ -106,7 +106,7 @@ proc genQueryUrl*(query: Query): string = # media doubles as the profile media tab, where f isn't part of the URL scheme if query.kind in {tweets, users, lists, top} or - (query.kind == media and query.fromUser.len == 0): + (query.kind == QueryKind.media and query.fromUser.len == 0): params.add &"f={query.kind}" if query.text.len > 0: params.add "q=" & encodeUrl(query.text) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index f2582c1..f56a2cb 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -68,7 +68,7 @@ proc createRssRouter*(cfg: Config) = let prefs = requestPrefs() query = initQuery(params(request)) - if query.kind notin {tweets, top, media}: + if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) let diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 6e8969e..35ef1aa 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -53,7 +53,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] let rail = - skipIf(skipRail or query.kind == media, @[]): + skipIf(skipRail or query.kind == QueryKind.media, @[]): getCachedPhotoRail(userId) user = getCachedUser(name) diff --git a/src/views/profile.nim b/src/views/profile.nim index de09368..c9012ed 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -115,7 +115,7 @@ proc renderProtected*(username: string): VNode = proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = profile.tweets.query.fromUser = @[profile.user.username] let - isGalleryView = profile.tweets.query.kind == media and + isGalleryView = profile.tweets.query.kind == QueryKind.media and profile.tweets.query.view == "gallery" viewClass = if isGalleryView: " media-only" else: "" diff --git a/src/views/search.nim b/src/views/search.nim index fe50280..1c29203 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -120,7 +120,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = let query = results.query let containerClass = - if query.fromUser.len == 0 and query.kind == media and + if query.fromUser.len == 0 and query.kind == QueryKind.media and query.view == "gallery": "timeline-container media-only" else: "timeline-container" buildHtml(tdiv(class=containerClass)): @@ -129,18 +129,18 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; text query.fromUser.join(" | ") if query.fromUser.len > 0: - if query.kind != media or query.view != "gallery": + if query.kind != QueryKind.media or query.view != "gallery": renderProfileTabs(query, query.fromUser.join(",")) - if query.kind == media and query.fromUser.len == 1: + if query.kind == QueryKind.media and query.fromUser.len == 1: renderMediaViewTabs(query) - if query.fromUser.len == 0 or query.kind == tweets: + if query.fromUser.len == 0 or query.kind == QueryKind.tweets: tdiv(class="timeline-header"): renderSearchPanel(query) if query.fromUser.len == 0: renderSearchTabs(query) - if query.kind == media: + if query.kind == QueryKind.media: renderMediaViewTabs(query) renderTimelineTweets(results, prefs, path, pinned) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 9c85c9c..2890dd1 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -6,7 +6,7 @@ import ".."/[types, query, formatters] import tweet, renderutils proc timelineViewClass(query: Query): string = - if query.kind != media: + if query.kind != QueryKind.media: return "timeline" case query.view From 092397cd6fbb6eaba8952e01c3415c73635a6527 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jul 2026 00:11:46 +0200 Subject: [PATCH 270/309] Show related tweets under replies Fixes #1334 --- src/parser.nim | 19 ++++++++++++++++++- src/prefs_impl.nim | 3 +++ src/sass/tweet/thread.scss | 9 +++++++++ src/types.nim | 1 + src/views/general.nim | 2 +- src/views/status.nim | 15 ++++++++++++++- 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 30f70d2..0b3352c 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -710,10 +710,26 @@ proc parseGraphTweet*(js: JsonNode): Tweet = with birdwatch, js{"birdwatch_pivot"}: result.note = parseCommunityNote(birdwatch) +proc getConvSection(js: JsonNode): string = + let details = select( + js{"item", "client_event_info", "details"}, + js{"item", "clientEventInfo", "details"} + ) + select( + details{"conversation_details", "conversation_section"}, + details{"conversationDetails", "conversationSection"} + ).getStr + proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = + var checkedSection = false for t in ? js{"content", "items"}: let entryId = t.getEntryId if "tweet-" in entryId and "promoted" notin entryId: + if not checkedSection: + checkedSection = true + if getConvSection(t) == "RelatedTweet": + result.thread.related = true + let tweet = t.getTweetResult("item") if tweet.notNull: result.thread.content.add parseGraphTweet(tweet) @@ -774,7 +790,8 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.before.content.add tweet elif not entryId.endsWith(tweetId): result.before.content.add Tweet(id: entryId.getId) - elif entryId.startsWith("conversationthread"): + elif entryId.startsWith("conversationthread") or + entryId.startsWith("tweetdetailrelatedtweets"): let (thread, self) = parseGraphThread(e) if self: result.after = thread diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 699ec4c..8519bd5 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -78,6 +78,9 @@ genPrefs: hideReplies(checkbox, false): "Hide tweet replies" + hideRelated(checkbox, true): + "Hide related tweets under replies" + hideCommunityNotes(checkbox, false): "Hide community notes" diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index c7b50ba..134e375 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -185,3 +185,12 @@ } } } + +.related-header { + padding: 8px 12px; + margin-top: 10px; + background-color: var(--bg_panel); + color: var(--fg_faded); + font-size: 14px; + border-bottom: 1px solid var(--border_grey); +} diff --git a/src/types.nim b/src/types.nim index d19a10d..bb521c7 100644 --- a/src/types.nim +++ b/src/types.nim @@ -359,6 +359,7 @@ type content*: Tweets hasMore*: bool cursor*: string + related*: bool Conversation* = ref object tweet*: Tweet diff --git a/src/views/general.nim b/src/views/general.nim index afdda9f..f223251 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=50") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=51") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/src/views/status.nim b/src/views/status.nim index 39af8fa..b16c211 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -1,4 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only +import sequtils import karax/[karaxdsl, vdom] import ".."/[types, formatters] @@ -48,7 +49,7 @@ proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; var hasReplies = false var replyCount = 0 for thread in replies.content: - if thread.content.len == 0: continue + if thread.content.len == 0 or thread.related: continue hasReplies = true replyCount += thread.content.len renderReplyThread(thread, prefs, path) @@ -58,6 +59,14 @@ proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; let extra = if sort == Relevance: "" else: "sort=" & $sort & "&" renderMore(Query(), replies.bottom, focus="#r", extra=extra) +proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="related-tweets")): + tdiv(class="related-header"): + text "Related tweets" + for thread in replies.content: + if thread.content.len == 0 or not thread.related: continue + renderReplyThread(thread, prefs, path) + proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; sort = Relevance): VNode = let hasAfter = conv.after.content.len > 0 @@ -95,6 +104,10 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; renderReplySort(sort) renderReplies(conv.replies, prefs, path, conv.tweet, sort) + if not prefs.hideRelated: + if conv.replies.content.anyIt(it.related and it.content.len > 0): + renderRelated(conv.replies, prefs, path) + renderToTop(focus="#m") proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode = From 5100290f76320dd1eceb48925f048b9f40b0f863 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jul 2026 00:12:02 +0200 Subject: [PATCH 271/309] Speed up pytest with eager page loads --- tests/conftest.py | 4 ++++ tests/pyproject.toml | 2 +- tests/test_security.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d87c74 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +from seleniumbase.config import settings + +settings.SKIP_JS_WAITS = True +settings.WAIT_FOR_RSC_ON_PAGE_LOADS = False diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 323f114..63c8de7 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--timeout_multiplier=3" +addopts = "--pls=eager --reruns=3 --timeout_multiplier=3" diff --git a/tests/test_security.py b/tests/test_security.py index 65d5e9a..04ba680 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -8,7 +8,7 @@ def curl_status(url): """Get HTTP status code using curl to avoid URL normalization by Python libs.""" result = subprocess.run( ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', url], - capture_output=True, text=True, timeout=10 + capture_output=True, text=True, timeout=30 ) return int(result.stdout) From 3bc78801b39f0a98f1a424da3f60c4aa60c7025a Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 11 Jul 2026 07:54:00 +0200 Subject: [PATCH 272/309] Increase test reruns and timeout for proxy media --- tests/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 63c8de7..a0def28 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--pls=eager --reruns=3 --timeout_multiplier=3" +addopts = "--pls=eager --reruns=5 --timeout_multiplier=5" From 06c52473ab46741d1d78cee0067b3751f1ada706 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 18 Jul 2026 12:46:30 +0200 Subject: [PATCH 273/309] Escape HTML in mention and URL display text --- src/parserutils.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parserutils.nim b/src/parserutils.nim index 8db96a5..6fbed4f 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -233,9 +233,9 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; symbol = $runes[rep.slice.a] result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) of rkMention: - result.add a($runes[rep.slice], href = rep.url, title = rep.display) + result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display)) of rkUrl: - result.add a(rep.display, href = rep.url) + result.add a(escape(rep.display), href = rep.url) of rkRemove: discard From 9be0b8f826bc7442d7a1324e3258f866f3a19d76 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 18 Jul 2026 12:46:43 +0200 Subject: [PATCH 274/309] Add tweet embed and oEmbed API support --- public/js/embedResize.js | 26 +++++++ public/js/embedTweet.js | 23 ++++++ public/js/widgets.js | 135 ++++++++++++++++++++++++++++++++++++ src/routes/embed.nim | 71 +++++++++++++++++-- src/routes/router_utils.nim | 2 +- src/sass/tweet/_base.scss | 104 ++++++++++++++++++++++++--- src/views/embed.nim | 42 ++++++++++- src/views/general.nim | 2 +- src/views/oembed.nimf | 6 ++ src/views/tweet.nim | 11 --- 10 files changed, 390 insertions(+), 32 deletions(-) create mode 100644 public/js/embedResize.js create mode 100644 public/js/embedTweet.js create mode 100644 public/js/widgets.js create mode 100644 src/views/oembed.nimf diff --git a/public/js/embedResize.js b/public/js/embedResize.js new file mode 100644 index 0000000..227d6af --- /dev/null +++ b/public/js/embedResize.js @@ -0,0 +1,26 @@ +(function() { + var embedElement = document.querySelector('.tweet-embed, .embed-video'); + if (!embedElement) return; + + var lastHeight = 0; + + function sendHeight() { + var currentHeight = embedElement.offsetHeight; + if (currentHeight !== lastHeight) { + lastHeight = currentHeight; + window.parent.postMessage(['resizeIframe', { h: currentHeight, url: location.href }], '*'); + } + } + + // Respond to height requests from parent via MessageChannel + window.addEventListener('message', function(event) { + if (event.source === window.parent && event.ports && event.ports[0]) { + event.ports[0].postMessage(embedElement.offsetHeight); + } + }); + + window.addEventListener('load', sendHeight); + new ResizeObserver(sendHeight).observe(embedElement); + + return sendHeight; +})() diff --git a/public/js/embedTweet.js b/public/js/embedTweet.js new file mode 100644 index 0000000..5b6419d --- /dev/null +++ b/public/js/embedTweet.js @@ -0,0 +1,23 @@ +// This runs after embedResize.js sets up the sendHeight function +(function(sendHeight) { + // Make images load eagerly so height updates correctly + var lazyImages = document.querySelectorAll('img[loading="lazy"]'); + for (var i = 0; i < lazyImages.length; i++) { + lazyImages[i].loading = 'eager'; + } + + // Update height when images finish loading + var allImages = document.querySelectorAll('img'); + for (var i = 0; i < allImages.length; i++) { + var img = allImages[i]; + if (!img.complete) { + img.addEventListener('load', sendHeight); + } + } + + // Open all links in new tab (we're in an iframe) + var allLinks = document.querySelectorAll('a'); + for (var i = 0; i < allLinks.length; i++) { + allLinks[i].target = '_blank'; + } +})(arguments[0]); diff --git a/public/js/widgets.js b/public/js/widgets.js new file mode 100644 index 0000000..0b897ef --- /dev/null +++ b/public/js/widgets.js @@ -0,0 +1,135 @@ +/** + * Drop-in replacement for Twitter's widgets.js + * Include this script to automatically convert twitter-tweet blockquotes to Nitter embeds + */ +(function () { + "use strict"; + + // Determine the Nitter instance URL from the script src, or fall back to current origin + var widgetScripts = document.querySelectorAll('script[src*="widgets.js"]'); + var NITTER_URL = widgetScripts.length + ? new URL(widgetScripts[widgetScripts.length - 1].src).origin + : location.origin; + + var TWEET_URL_PATTERN = + /^https?:\/\/(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + + // Track iframes by URL for resize messages + var iframesByUrl = {}; + + /** + * Extract tweet info (username and ID) from a blockquote's links + */ + function findTweetInfo(blockquote) { + var links = blockquote.querySelectorAll("a"); + for (var i = 0; i < links.length; i++) { + var match = TWEET_URL_PATTERN.exec(links[i].href); + if (match) { + return { username: match[1], tweetId: match[2] }; + } + } + return null; + } + + /** + * Transform all twitter-tweet blockquotes into Nitter embed iframes + */ + function transformBlockquotes() { + var blockquotes = document.querySelectorAll("blockquote.twitter-tweet"); + + for (var i = 0; i < blockquotes.length; i++) { + var blockquote = blockquotes[i]; + var tweetInfo = findTweetInfo(blockquote); + if (!tweetInfo) continue; + + var embedUrl = + NITTER_URL + + "/" + + tweetInfo.username + + "/status/" + + tweetInfo.tweetId + + "/embed"; + + var iframe = document.createElement("iframe"); + iframe.src = embedUrl; + iframe.style.cssText = + "width: 100%; max-width: 550px; height: 250px; border: none; display: block;"; + iframe.loading = "lazy"; + + // Track iframe for resize messages + if (!iframesByUrl[embedUrl]) { + iframesByUrl[embedUrl] = []; + } + iframesByUrl[embedUrl].push(iframe); + + blockquote.parentNode.replaceChild(iframe, blockquote); + } + } + + /** + * Handle resize messages from Nitter embeds + */ + function handleResizeMessage(event) { + if (!Array.isArray(event.data) || event.data[0] !== "resizeIframe") return; + + var data = event.data[1]; + if (!data.h || data.h <= 0) return; + + var iframes = iframesByUrl[data.url]; + if (iframes) { + for (var i = 0; i < iframes.length; i++) { + iframes[i].style.height = data.h + "px"; + } + } + } + + // Remove any Twitter widget scripts that might have been loaded + var twitterScripts = document.querySelectorAll( + 'script[src*="platform.twitter.com/widgets.js"], script[src*="platform.x.com/widgets.js"]', + ); + for (var i = 0; i < twitterScripts.length; i++) { + twitterScripts[i].remove(); + } + + // Listen for resize messages from embeds + window.addEventListener("message", handleResizeMessage); + + // Transform existing blockquotes + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", transformBlockquotes); + } else { + transformBlockquotes(); + } + + // Watch for dynamically added blockquotes + var observer = new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var addedNodes = mutations[i].addedNodes; + for (var j = 0; j < addedNodes.length; j++) { + var node = addedNodes[j]; + if (node.nodeType !== 1) continue; + + var isTwitterBlockquote = + node.matches && node.matches("blockquote.twitter-tweet"); + var containsTwitterBlockquote = + node.querySelector && node.querySelector("blockquote.twitter-tweet"); + + if (isTwitterBlockquote || containsTwitterBlockquote) { + transformBlockquotes(); + return; + } + } + } + }); + + if (document.body) { + observer.observe(document.body, { childList: true, subtree: true }); + } + + // Provide a fake twttr object for compatibility with sites that check for it + window.twttr = window.twttr || {}; + window.twttr.widgets = { + load: transformBlockquotes, + loaded: true, + }; +})(); diff --git a/src/routes/embed.nim b/src/routes/embed.nim index bdca60f..179ec23 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -1,18 +1,45 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils, strformat, options +import asyncdispatch, strutils, strformat, json import jester, karax/vdom -import ".."/[types, api] +import ".."/[types, api, formatters] import ../views/[embed, tweet, general] +include "../views/oembed.nimf" import router_utils export api, embed, vdom, tweet, general, router_utils +proc parseTweetUrl*(url: string): tuple[username, id: string] = + var path = url + if path.startsWith("https://"): + path = path[8..^1] + elif path.startsWith("http://"): + path = path[7..^1] + + const prefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", + "www.twitter.com/", "www.x.com/"] + for prefix in prefixes: + if path.startsWith(prefix): + path = path[prefix.len..^1] + let parts = path.split('/') + if parts.len >= 3 and parts[1] == "status": + let tweetId = parts[2].split('?')[0].split('#')[0] + if tweetId.len > 0 and tweetId.allCharsInSet(Digits): + return (parts[0], tweetId) + break + return ("", "") + proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": - let tweet = await getGraphTweetResult(@"id") - if tweet == nil or not tweet.hasVideos: - resp Http404 + let + tweet = await getGraphTweetResult(@"id") + prefs = requestPrefs() + + if tweet == nil: + resp renderErrorEmbed("Tweet not found", prefs, cfg, request) + + if not tweet.hasVideos: + resp renderErrorEmbed("No video in tweet", prefs, cfg, request) resp renderVideoEmbed(tweet, cfg, request) @@ -23,7 +50,7 @@ proc createEmbedRouter*(cfg: Config) = path = getPath() if tweet == nil: - resp Http404 + resp renderErrorEmbed("Tweet not found", prefs, cfg, request) resp renderTweetEmbed(tweet, path, prefs, cfg, request) @@ -34,3 +61,35 @@ proc createEmbedRouter*(cfg: Config) = redirect(&"/i/status/{id}/embed") else: resp Http404 + + get "/api/oembed": + let url = @"url" + if url.len == 0: + resp Http400, "Missing url parameter" + + let (username, tweetId) = parseTweetUrl(url) + if username.len == 0 or tweetId.len == 0: + resp Http400, "Invalid tweet URL" + + let tweet = await getGraphTweetResult(tweetId) + if tweet == nil: + resp Http404 + + let + embedUrl = getUrlPrefix(cfg) & "/" & username & "/status/" & tweetId & "/embed" + authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username + + responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) + respJson %*{ + "version": "1.0", + "type": "rich", + "provider_name": cfg.title, + "provider_url": getUrlPrefix(cfg), + "author_name": tweet.user.fullname, + "author_url": authorUrl, + "url": embedUrl, + "width": 550, + "height": nil, + "cache_age": "3153600000", + "html": renderOembedIframe(embedUrl) + } diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index 8a26193..612a96b 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -4,7 +4,7 @@ from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri +export utils, prefs, types, uri, json template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 93d2086..10f8b00 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -118,30 +118,114 @@ } } +body:has(> .tweet-embed) { + margin: 0; + padding: 0; + width: 100%; + min-height: 100%; + background: transparent; + overflow: hidden; +} + +html:has(body > .tweet-embed) { + background: transparent; + overflow: hidden; +} + .tweet-embed { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; + position: relative; + width: 100%; + box-sizing: border-box; background-color: var(--bg_panel); + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--bg_hover); + } + + .timeline-item { + pointer-events: none; + } .tweet-content { font-size: 18px; + pointer-events: none; + + a { + pointer-events: all; + } + } + + .attachments { + pointer-events: none; + + a, video, .video-overlay { + pointer-events: all; + } } .tweet-body { display: flex; flex-direction: column; - max-height: calc(100vh - 0.75em * 2); } - .card-image img { - height: auto; - } - - .avatar { + .avatar:not(.mini) { position: absolute; } + + .quote-media-container { + max-height: 600px; + } + + .quote-media-container .gallery-row .attachment, + .quote-media-container .gallery-row .attachment > video, + .quote-media-container .gallery-row .attachment > img, + .quote-media-container .still-image, + .quote-media-container .still-image img, + .still-image, + .still-image img { + max-height: 600px; + } + + &.error-embed { + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; + padding: 20px; + cursor: default; + + &:hover { + background-color: var(--bg_panel); + } + + .error-panel { + margin: 0; + } + } + + .embed-footer { + position: relative; + z-index: 2; + display: block; + padding: 12px 16px; + border-top: 1px solid var(--border_grey); + background: var(--bg_elements); + color: var(--accent); + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + pointer-events: all; + transition: background-color 0.15s; + + &:hover { + background: var(--bg_hover); + } + } } .attribution { diff --git a/src/views/embed.nim b/src/views/embed.nim index 62cc76f..80b2dba 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -1,15 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-only -import options import karax/[karaxdsl, vdom] from jester import Request import ".."/[types, formatters] import general, tweet -const doctype = "\n" +const + doctype = "\n" + embedResizeJs = staticRead("../../public/js/embedResize.js") + embedTweetJs = embedResizeJs & staticRead("../../public/js/embedTweet.js") + embedErrorJs = embedResizeJs & ";requestAnimationFrame(arguments[0]);" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = - let + let video = tweet.getVideos()[0] thumb = video.thumb vidUrl = getVideoEmbed(cfg, tweet.id) @@ -22,4 +25,37 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = tdiv(class="embed-video"): renderVideo(video, prefs, "") + script: + verbatim embedResizeJs + + result = doctype & $node + +proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = + let node = buildHtml(html(lang="en")): + renderHead(prefs, cfg, req) + + body: + tdiv(class="tweet-embed"): + a(class="tweet-link", href=getLink(tweet), target="_blank") + renderTweet(tweet, prefs, path, mainTweet=true) + a(class="embed-footer", href=getLink(tweet), target="_blank"): + text "Read more on " & cfg.hostname + + script: + verbatim embedTweetJs + + result = doctype & $node + +proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request): string = + let node = buildHtml(html(lang="en")): + renderHead(prefs, cfg, req) + + body: + tdiv(class="tweet-embed error-embed"): + tdiv(class="error-panel"): + span: text error + + script: + verbatim embedErrorJs + result = doctype & $node diff --git a/src/views/general.nim b/src/views/general.nim index f223251..5ba6f7c 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=51") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=52") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf new file mode 100644 index 0000000..34f3ccc --- /dev/null +++ b/src/views/oembed.nimf @@ -0,0 +1,6 @@ +#? stdtmpl(subsChar = '$', metaChar = '#') +## SPDX-License-Identifier: AGPL-3.0-only +#proc renderOembedIframe*(embedUrl: string): string = +# result = "" + +#end proc diff --git a/src/views/tweet.nim b/src/views/tweet.nim index b66269a..8971ab8 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -5,7 +5,6 @@ from jester import Request import renderutils import ".."/[types, utils, formatters] -import general const doctype = "\n" @@ -464,13 +463,3 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if not prefs.hideTweetStats: renderStats(tweet.stats) - -proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = - let node = buildHtml(html(lang="en")): - renderHead(prefs, cfg, req) - - body: - tdiv(class="tweet-embed"): - renderTweet(tweet, prefs, path, mainTweet=true) - - result = doctype & $node From 6d41157603f4d58be2ae680d443fe641f2cf3b41 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 19 Jul 2026 22:30:52 +0200 Subject: [PATCH 275/309] Fix JS concatenation in tweet embeds Fixes #586 --- public/js/embedResize.js | 3 ++- public/js/embedTweet.js | 7 +++++-- src/views/embed.nim | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 227d6af..82c97cc 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -22,5 +22,6 @@ window.addEventListener('load', sendHeight); new ResizeObserver(sendHeight).observe(embedElement); - return sendHeight; + // Expose for embedTweet.js + window._nitterSendHeight = sendHeight; })() diff --git a/public/js/embedTweet.js b/public/js/embedTweet.js index 5b6419d..74d2577 100644 --- a/public/js/embedTweet.js +++ b/public/js/embedTweet.js @@ -1,5 +1,8 @@ // This runs after embedResize.js sets up the sendHeight function -(function(sendHeight) { +(function() { + var sendHeight = window._nitterSendHeight; + if (!sendHeight) return; + // Make images load eagerly so height updates correctly var lazyImages = document.querySelectorAll('img[loading="lazy"]'); for (var i = 0; i < lazyImages.length; i++) { @@ -20,4 +23,4 @@ for (var i = 0; i < allLinks.length; i++) { allLinks[i].target = '_blank'; } -})(arguments[0]); +})(); diff --git a/src/views/embed.nim b/src/views/embed.nim index 80b2dba..27cbb74 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -8,8 +8,8 @@ import general, tweet const doctype = "\n" embedResizeJs = staticRead("../../public/js/embedResize.js") - embedTweetJs = embedResizeJs & staticRead("../../public/js/embedTweet.js") - embedErrorJs = embedResizeJs & ";requestAnimationFrame(arguments[0]);" + embedTweetJs = embedResizeJs & ";" & staticRead("../../public/js/embedTweet.js") + embedErrorJs = embedResizeJs & ";if(window._nitterSendHeight)requestAnimationFrame(window._nitterSendHeight);" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = let From ccde22aa0bc9b3c086f2e8769510e7c49a94938b Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 8 Aug 2026 23:00:19 +0700 Subject: [PATCH 276/309] Use TweetResultByRestId for embeds --- src/api.nim | 7 +++++++ src/consts.nim | 10 +++++++++- src/parser.nim | 4 ++++ src/routes/embed.nim | 6 +++--- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/api.nim b/src/api.nim index 041a492..ca3e659 100644 --- a/src/api.nim +++ b/src/api.nim @@ -230,6 +230,13 @@ proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = js = await fetch(url) result = parseGraphTweetResult(js) +proc getTweetByRestId*(id: string): Future[Tweet] {.async.} = + if id.len == 0: return + let + url = apiReq(graphTweetResultByRestId, tweetByRestIdVars % id, articleFieldToggles) + js = await fetch(url) + result = parseTweetByRestId(js) + proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = if id.len == 0: return let diff --git a/src/consts.nim b/src/consts.nim index afef54d..ff4aa42 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -35,7 +35,7 @@ const graphCommunityModerators* = "GBMT3GOWy5dYsYC4XJfvow/moderatorsSliceTimeline_Query" graphCommunityHashtags* = "40DyrMxfCknGuZwE-keW_Q/CommunityHashtagsTimeline" - graphTweetResultByRestId* = "qtXMy1p5Y62uCskc_NUPJw/TweetResultByRestId" + graphTweetResultByRestId* = "4hhGRbehkcUVTKf8n0f0xw/TweetResultByRestId" graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds" graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery" @@ -166,6 +166,14 @@ const articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}""" + tweetByRestIdVars* = """{ + "tweetId": "$1", + "includePromotedContent": false, + "withBirdwatchNotes": false, + "withVoice": false, + "withCommunity": false +}""".replace(" ", "").replace("\n", "") + communityTweetsVars* = """{ "communityId": "$1", $2 "count": 20, diff --git a/src/parser.nim b/src/parser.nim index 0b3352c..fb5cca4 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -751,6 +751,10 @@ proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet) +proc parseTweetByRestId*(js: JsonNode): Tweet = + with tweet, js{"data", "tweetResult", "result"}: + result = parseGraphTweet(tweet) + proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] = let results = js{"data", "tweetResult"} if results.kind != JArray: return diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 179ec23..5c45f29 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -32,7 +32,7 @@ proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": let - tweet = await getGraphTweetResult(@"id") + tweet = await getTweetByRestId(@"id") prefs = requestPrefs() if tweet == nil: @@ -45,7 +45,7 @@ proc createEmbedRouter*(cfg: Config) = get "/@user/status/@id/embed": let - tweet = await getGraphTweetResult(@"id") + tweet = await getTweetByRestId(@"id") prefs = requestPrefs() path = getPath() @@ -71,7 +71,7 @@ proc createEmbedRouter*(cfg: Config) = if username.len == 0 or tweetId.len == 0: resp Http400, "Invalid tweet URL" - let tweet = await getGraphTweetResult(tweetId) + let tweet = await getTweetByRestId(tweetId) if tweet == nil: resp Http404 From dc66bd637571df9182c147e2956294e226718ff5 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 8 Aug 2026 23:00:36 +0700 Subject: [PATCH 277/309] Fix embed click-through with CSS-only approach --- src/sass/tweet/_base.scss | 30 ++++++++---------------------- src/views/general.nim | 2 +- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 10f8b00..3623a19 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -87,7 +87,6 @@ margin-top: 6px; margin-bottom: 0px; color: var(--grey); - pointer-events: all; } .tweet-avatar { @@ -122,7 +121,6 @@ body:has(> .tweet-embed) { margin: 0; padding: 0; width: 100%; - min-height: 100%; background: transparent; overflow: hidden; } @@ -142,34 +140,22 @@ html:has(body > .tweet-embed) { overflow: hidden; transition: background-color 0.15s ease; - &:hover { + &:hover:not(:has(.embed-footer:hover)) { background-color: var(--bg_hover); } .timeline-item { + display: block; pointer-events: none; + background-color: transparent; + } + + .tweet-link:hover { + background-color: transparent; } .tweet-content { font-size: 18px; - pointer-events: none; - - a { - pointer-events: all; - } - } - - .attachments { - pointer-events: none; - - a, video, .video-overlay { - pointer-events: all; - } - } - - .tweet-body { - display: flex; - flex-direction: column; } .avatar:not(.mini) { @@ -213,7 +199,7 @@ html:has(body > .tweet-embed) { display: block; padding: 12px 16px; border-top: 1px solid var(--border_grey); - background: var(--bg_elements); + background: var(--bg_panel); color: var(--accent); font-size: 14px; font-weight: 500; diff --git a/src/views/general.nim b/src/views/general.nim index 5ba6f7c..d085ad5 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=52") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=84") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: From 3bfbb7b3fd6e9e958941142058210be26633a4e9 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 8 Aug 2026 23:00:47 +0700 Subject: [PATCH 278/309] Clean up embed JS and add Tweet.html compatibility --- public/js/embedResize.js | 2 +- public/js/embedTweet.js | 16 +++++++++------- src/nitter.nim | 4 ++-- src/views/oembed.nimf | 1 + 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 82c97cc..9a43379 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -24,4 +24,4 @@ // Expose for embedTweet.js window._nitterSendHeight = sendHeight; -})() +})(); diff --git a/public/js/embedTweet.js b/public/js/embedTweet.js index 74d2577..166f253 100644 --- a/public/js/embedTweet.js +++ b/public/js/embedTweet.js @@ -1,7 +1,6 @@ // This runs after embedResize.js sets up the sendHeight function (function() { var sendHeight = window._nitterSendHeight; - if (!sendHeight) return; // Make images load eagerly so height updates correctly var lazyImages = document.querySelectorAll('img[loading="lazy"]'); @@ -9,12 +8,14 @@ lazyImages[i].loading = 'eager'; } - // Update height when images finish loading - var allImages = document.querySelectorAll('img'); - for (var i = 0; i < allImages.length; i++) { - var img = allImages[i]; - if (!img.complete) { - img.addEventListener('load', sendHeight); + // Update height when images finish loading (only if sendHeight available) + if (sendHeight) { + var allImages = document.querySelectorAll('img'); + for (var i = 0; i < allImages.length; i++) { + var img = allImages[i]; + if (!img.complete) { + img.addEventListener('load', sendHeight); + } } } @@ -23,4 +24,5 @@ for (var i = 0; i < allLinks.length; i++) { allLinks[i].target = '_blank'; } + })(); diff --git a/src/nitter.nim b/src/nitter.nim index dc38161..a76dda6 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -81,8 +81,8 @@ routes: if request.path.len == 0 or request.path[0] != '/': halt Http400 - # skip all file URLs - cond "." notin request.path + # skip all file URLs (except Twitter widget compatibility) + cond "." notin request.path or request.path == "/embed/Tweet.html" applyUrlPrefs() get "/": diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf index 34f3ccc..3dc793b 100644 --- a/src/views/oembed.nimf +++ b/src/views/oembed.nimf @@ -3,4 +3,5 @@ #proc renderOembedIframe*(embedUrl: string): string = # result = "" +# result = result.strip() #end proc From a39e17abbebf515f7dabde6c9fa13ea485310798 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 8 Aug 2026 23:09:39 +0700 Subject: [PATCH 279/309] Add embed test suite --- tests/test_embed.py | 204 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/test_embed.py diff --git a/tests/test_embed.py b/tests/test_embed.py new file mode 100644 index 0000000..c15d247 --- /dev/null +++ b/tests/test_embed.py @@ -0,0 +1,204 @@ +import requests +from base import BaseTestCase, Media +from parameterized import parameterized + + +class Embed: + container = '.tweet-embed' + footer = '.embed-footer' + tweet_content = '.tweet-content' + tweet_header = '.tweet-header' + fullname = '.fullname' + username = '.username' + avatar = '.avatar' + stats = '.tweet-stats' + quote = '.quote' + error_panel = '.error-panel' + + +class TweetEmbedTest(BaseTestCase): + """Test tweet embed rendering.""" + tweet = 'elonmusk/status/1141367104702038016' + + def test_embed_container_visible(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.container) + + def test_embed_has_footer(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.footer) + self.assert_text_visible('Read more on', Embed.footer) + + def test_embed_has_tweet_content(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.tweet_content) + + def test_embed_has_avatar(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.avatar) + + def test_embed_has_username(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.username) + + def test_embed_has_stats(self): + self.open_nitter(self.tweet + '/embed') + self.assert_element_visible(Embed.stats) + + def test_embed_footer_links_to_tweet(self): + self.open_nitter(self.tweet + '/embed') + href = self.get_attribute(Embed.footer, 'href') + self.assertIn('/elonmusk/status/1141367104702038016', href) + + +class TweetEmbedMediaTest(BaseTestCase): + """Test embed rendering with various media types.""" + + def test_embed_with_image(self): + self.open_nitter('mobile_test/status/519364660823207936/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.image) + + def test_embed_with_gif(self): + self.open_nitter('elonmusk/status/1141367104702038016/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.gif) + + def test_embed_with_video(self): + self.open_nitter('d0m96/status/1078373829917974528/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.video) + + def test_embed_with_gallery(self): + self.open_nitter('mobile_test/status/451108446603980803/embed') + self.assert_element_visible(Embed.container) + self.scroll_to(Media.container) + self.assert_element_visible(Media.row) + + +class TweetEmbedQuoteTest(BaseTestCase): + """Test embed rendering with quoted tweets.""" + + def test_embed_with_quote_shows_quote(self): + self.open_nitter('elonmusk/status/1138827760107790336/embed') + self.assert_element_visible(Embed.container) + self.assert_element_visible(Embed.quote) + + def test_embed_quote_has_content(self): + self.open_nitter('elonmusk/status/1138827760107790336/embed') + quote = self.find_element(Embed.quote) + self.assertIsNotNone(quote.text) + + +class EmbedErrorTest(BaseTestCase): + """Test embed error handling.""" + + def test_nonexistent_tweet_shows_error(self): + self.open_nitter('nobody/status/1/embed') + self.assert_element_visible('.tweet-embed.error-embed') + self.assert_text_visible('not found', Embed.error_panel) + + def test_protected_account_embed_shows_error(self): + self.open_nitter('mobile_test_7/status/1/embed') + self.assert_element_visible('.tweet-embed.error-embed') + + def test_invalid_tweet_id_shows_error(self): + self.open_nitter('jack/status/notanumber/embed') + self.assert_element_visible('.tweet-embed.error-embed') + + +class OEmbedApiTest(BaseTestCase): + """Test oEmbed API endpoint.""" + base_url = 'http://localhost:8080' + tweet_url = 'https://twitter.com/elonmusk/status/1141367104702038016' + + def test_oembed_returns_json(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.headers['Content-Type'], 'application/json') + + def test_oembed_has_required_fields(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertEqual(data['type'], 'rich') + self.assertEqual(data['version'], '1.0') + self.assertIn('html', data) + self.assertIn('author_name', data) + self.assertIn('provider_name', data) + + def test_oembed_html_contains_iframe(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIn(' Date: Sun, 9 Aug 2026 00:15:58 +0700 Subject: [PATCH 280/309] Improve flaky tests --- tests/test_article.py | 2 ++ tests/test_tweet_media.py | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/test_article.py b/tests/test_article.py index 652ec76..287b948 100644 --- a/tests/test_article.py +++ b/tests/test_article.py @@ -291,6 +291,8 @@ class ArticleQuotedCardTest(BaseTestCase): def test_quoted_card_has_cover_image(self): self.open_nitter(self.quoted_tweet) + # Scroll to element to trigger lazy loading + self.scroll_to('.quote .article-card .card-image img') self.assert_element_visible('.quote .article-card .card-image img') src = self.get_attribute('.quote .article-card .card-image img', 'src') self.assertIn('/pic/', src) diff --git a/tests/test_tweet_media.py b/tests/test_tweet_media.py index 65e6edb..7f81391 100644 --- a/tests/test_tweet_media.py +++ b/tests/test_tweet_media.py @@ -101,6 +101,7 @@ class MediaTest(BaseTestCase): @parameterized.expand(gallery) def test_gallery(self, tweet, rows): self.open_nitter(tweet) + self.scroll_to(Media.container) self.assert_element_visible(Media.container) self.assert_element_visible(Media.row) self.assert_element_visible(Media.image) From a00c6cc11d9c224684154129ad115df9f9ea7541 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 9 Aug 2026 00:58:35 +0700 Subject: [PATCH 281/309] Simplify embed hover CSS with wrapper element --- public/js/embedResize.js | 2 +- src/sass/tweet/_base.scss | 56 ++++++++++++++++++++++----------------- src/views/embed.nim | 7 ++--- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 9a43379..486cbcb 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -1,5 +1,5 @@ (function() { - var embedElement = document.querySelector('.tweet-embed, .embed-video'); + var embedElement = document.querySelector('.embed-wrapper, .tweet-embed, .embed-video'); if (!embedElement) return; var lastHeight = 0; diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 3623a19..050eaad 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -117,6 +117,7 @@ } } +body:has(> .embed-wrapper), body:has(> .tweet-embed) { margin: 0; padding: 0; @@ -125,22 +126,45 @@ body:has(> .tweet-embed) { overflow: hidden; } +html:has(body > .embed-wrapper), html:has(body > .tweet-embed) { background: transparent; overflow: hidden; } +.embed-wrapper { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + + .embed-footer { + display: block; + padding: 12px 16px; + border-top: 1px solid var(--border_grey); + background: var(--bg_panel); + color: var(--accent); + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + transition: background-color 0.15s; + + &:hover { + background: var(--bg_hover); + } + } +} + .tweet-embed { position: relative; width: 100%; box-sizing: border-box; background-color: var(--bg_panel); - border: 1px solid var(--border_grey); - border-radius: 12px; - overflow: hidden; transition: background-color 0.15s ease; - &:hover:not(:has(.embed-footer:hover)) { + &:hover { background-color: var(--bg_hover); } @@ -180,8 +204,10 @@ html:has(body > .tweet-embed) { display: flex; align-items: center; justify-content: center; - min-height: 80px; + min-height: 160px; padding: 20px; + border: 1px solid var(--border_grey); + border-radius: 12px; cursor: default; &:hover { @@ -192,26 +218,6 @@ html:has(body > .tweet-embed) { margin: 0; } } - - .embed-footer { - position: relative; - z-index: 2; - display: block; - padding: 12px 16px; - border-top: 1px solid var(--border_grey); - background: var(--bg_panel); - color: var(--accent); - font-size: 14px; - font-weight: 500; - text-align: center; - text-decoration: none; - pointer-events: all; - transition: background-color 0.15s; - - &:hover { - background: var(--bg_hover); - } - } } .attribution { diff --git a/src/views/embed.nim b/src/views/embed.nim index 27cbb74..f52dd15 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -35,9 +35,10 @@ proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; re renderHead(prefs, cfg, req) body: - tdiv(class="tweet-embed"): - a(class="tweet-link", href=getLink(tweet), target="_blank") - renderTweet(tweet, prefs, path, mainTweet=true) + tdiv(class="embed-wrapper"): + tdiv(class="tweet-embed"): + a(class="tweet-link", href=getLink(tweet), target="_blank") + renderTweet(tweet, prefs, path, mainTweet=true) a(class="embed-footer", href=getLink(tweet), target="_blank"): text "Read more on " & cfg.hostname From 5733297f9295ae7aded565918b5de0b0060951b0 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 9 Aug 2026 02:11:50 +0700 Subject: [PATCH 282/309] Enhance widgets.js Twitter replacement --- public/js/widgets.js | 262 ++++++++++++++++++++++++++----------------- 1 file changed, 162 insertions(+), 100 deletions(-) diff --git a/public/js/widgets.js b/public/js/widgets.js index 0b897ef..5c8bc9c 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -1,135 +1,197 @@ /** * Drop-in replacement for Twitter's widgets.js - * Include this script to automatically convert twitter-tweet blockquotes to Nitter embeds + * Converts twitter-tweet/twitter-video blockquotes to Nitter embeds + * + * Usage: LibRedirect can redirect platform.twitter.com/widgets.js to nitter.net/widgets.js */ (function () { "use strict"; - // Determine the Nitter instance URL from the script src, or fall back to current origin - var widgetScripts = document.querySelectorAll('script[src*="widgets.js"]'); - var NITTER_URL = widgetScripts.length - ? new URL(widgetScripts[widgetScripts.length - 1].src).origin + // Idempotent - only run once + if (window.__nitterWidgets) return; + window.__nitterWidgets = true; + + // Determine Nitter instance URL from script src, or fall back to current origin + var scripts = document.querySelectorAll('script[src*="widgets.js"]'); + var NITTER = scripts.length + ? new URL(scripts[scripts.length - 1].src).origin : location.origin; - var TWEET_URL_PATTERN = - /^https?:\/\/(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + var TWEET_PATTERN = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video"; - // Track iframes by URL for resize messages - var iframesByUrl = {}; + // Ready callback queue + var readyCallbacks = []; + var isReady = false; - /** - * Extract tweet info (username and ID) from a blockquote's links - */ - function findTweetInfo(blockquote) { - var links = blockquote.querySelectorAll("a"); - for (var i = 0; i < links.length; i++) { - var match = TWEET_URL_PATTERN.exec(links[i].href); - if (match) { - return { username: match[1], tweetId: match[2] }; - } - } - return null; + function parseTweetUrl(url) { + if (!url) return null; + var match = TWEET_PATTERN.exec(url); + if (match) return { username: match[1], id: match[2] }; + // Fallback: extract any large number (tweet ID) + var idMatch = url.match(/(\d{15,})/); + return idMatch ? { username: null, id: idMatch[1] } : null; } - /** - * Transform all twitter-tweet blockquotes into Nitter embed iframes - */ - function transformBlockquotes() { - var blockquotes = document.querySelectorAll("blockquote.twitter-tweet"); + function createIframe(tweet, options) { + var embedUrl = tweet.username + ? NITTER + "/" + tweet.username + "/status/" + tweet.id + "/embed" + : NITTER + "/i/status/" + tweet.id + "/embed"; + + var iframe = document.createElement("iframe"); + iframe.src = embedUrl; + iframe.className = "nitter-embed-frame"; + iframe.setAttribute("allowtransparency", "true"); + iframe.setAttribute("frameborder", "0"); + iframe.setAttribute("scrolling", "no"); + iframe.loading = "lazy"; + + // Styling with data attribute support + var width = options.width || "550"; + var align = options.align || "center"; + var margin = align === "center" ? "10px auto" : + align === "right" ? "10px 0 10px auto" : "10px auto 10px 0"; + + iframe.style.cssText = "width:100%;max-width:" + width + "px;height:250px;border:none;display:block;margin:" + margin; + + return iframe; + } + + function processBlockquote(bq) { + if (bq.dataset.nitterProcessed) return false; + bq.dataset.nitterProcessed = "true"; + + // Find tweet URL in links + var tweet = null; + var links = bq.querySelectorAll("a[href]"); + for (var i = 0; i < links.length; i++) { + tweet = parseTweetUrl(links[i].href); + if (tweet) break; + } + + if (!tweet) { + console.warn("[Nitter widgets.js] No tweet URL found in blockquote"); + return false; + } + + // Read Twitter's data attributes + var options = { + width: bq.dataset.width, + align: bq.dataset.align, + theme: bq.dataset.theme + }; + + var iframe = createIframe(tweet, options); + + // Hide original (keep as fallback), insert iframe after + bq.style.display = "none"; + bq.parentNode.insertBefore(iframe, bq.nextSibling); + return true; + } + + function processEmbeds(container) { + var root = container || document; + var blockquotes = root.querySelectorAll(SELECTOR + ":not([data-nitter-processed])"); + var count = 0; for (var i = 0; i < blockquotes.length; i++) { - var blockquote = blockquotes[i]; - var tweetInfo = findTweetInfo(blockquote); - if (!tweetInfo) continue; + if (processBlockquote(blockquotes[i])) count++; + } - var embedUrl = - NITTER_URL + - "/" + - tweetInfo.username + - "/status/" + - tweetInfo.tweetId + - "/embed"; - - var iframe = document.createElement("iframe"); - iframe.src = embedUrl; - iframe.style.cssText = - "width: 100%; max-width: 550px; height: 250px; border: none; display: block;"; - iframe.loading = "lazy"; - - // Track iframe for resize messages - if (!iframesByUrl[embedUrl]) { - iframesByUrl[embedUrl] = []; - } - iframesByUrl[embedUrl].push(iframe); - - blockquote.parentNode.replaceChild(iframe, blockquote); + if (count > 0) { + console.log("[Nitter widgets.js] Processed " + count + " embed(s)"); } } - /** - * Handle resize messages from Nitter embeds - */ - function handleResizeMessage(event) { + function handleResize(event) { if (!Array.isArray(event.data) || event.data[0] !== "resizeIframe") return; + var h = event.data[1] && event.data[1].h; + if (!h || h <= 0) return; - var data = event.data[1]; - if (!data.h || data.h <= 0) return; - - var iframes = iframesByUrl[data.url]; - if (iframes) { - for (var i = 0; i < iframes.length; i++) { - iframes[i].style.height = data.h + "px"; + // Find iframe by matching contentWindow + var iframes = document.querySelectorAll("iframe.nitter-embed-frame"); + for (var i = 0; i < iframes.length; i++) { + if (iframes[i].contentWindow === event.source) { + iframes[i].style.height = h + "px"; + break; } } } - // Remove any Twitter widget scripts that might have been loaded + function observeDOM() { + if (!window.MutationObserver || !document.body) return; + + new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var nodes = mutations[i].addedNodes; + for (var j = 0; j < nodes.length; j++) { + var node = nodes[j]; + if (node.nodeType !== 1) continue; + if ((node.matches && node.matches(SELECTOR)) || + (node.querySelector && node.querySelector(SELECTOR))) { + processEmbeds(); + return; + } + } + } + }).observe(document.body, { childList: true, subtree: true }); + } + + function fireReady() { + isReady = true; + for (var i = 0; i < readyCallbacks.length; i++) { + try { readyCallbacks[i](window.twttr); } catch (e) {} + } + readyCallbacks = []; + } + + // Expose twttr API for compatibility + var prevTwttr = window.twttr; + window.twttr = { + widgets: { + load: function (el) { processEmbeds(el); }, + createTweet: function (id, container, opts) { + if (!container) return Promise.reject("No container"); + var iframe = createIframe({ id: id, username: null }, opts || {}); + container.appendChild(iframe); + return Promise.resolve(iframe); + }, + loaded: true + }, + ready: function (cb) { + if (typeof cb !== "function") return; + if (isReady) cb(window.twttr); + else readyCallbacks.push(cb); + }, + _e: [] + }; + + // Process any callbacks queued before we loaded (twttr._e pattern) + if (prevTwttr && prevTwttr._e) { + for (var i = 0; i < prevTwttr._e.length; i++) { + try { prevTwttr._e[i](); } catch (e) {} + } + } + + // Remove Twitter scripts that might have snuck in var twitterScripts = document.querySelectorAll( - 'script[src*="platform.twitter.com/widgets.js"], script[src*="platform.x.com/widgets.js"]', + 'script[src*="platform.twitter.com"], script[src*="platform.x.com"]' ); for (var i = 0; i < twitterScripts.length; i++) { twitterScripts[i].remove(); } - // Listen for resize messages from embeds - window.addEventListener("message", handleResizeMessage); + // Initialize + function init() { + window.addEventListener("message", handleResize); + processEmbeds(); + observeDOM(); + fireReady(); + } - // Transform existing blockquotes if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", transformBlockquotes); + document.addEventListener("DOMContentLoaded", init); } else { - transformBlockquotes(); + init(); } - - // Watch for dynamically added blockquotes - var observer = new MutationObserver(function (mutations) { - for (var i = 0; i < mutations.length; i++) { - var addedNodes = mutations[i].addedNodes; - for (var j = 0; j < addedNodes.length; j++) { - var node = addedNodes[j]; - if (node.nodeType !== 1) continue; - - var isTwitterBlockquote = - node.matches && node.matches("blockquote.twitter-tweet"); - var containsTwitterBlockquote = - node.querySelector && node.querySelector("blockquote.twitter-tweet"); - - if (isTwitterBlockquote || containsTwitterBlockquote) { - transformBlockquotes(); - return; - } - } - } - }); - - if (document.body) { - observer.observe(document.body, { childList: true, subtree: true }); - } - - // Provide a fake twttr object for compatibility with sites that check for it - window.twttr = window.twttr || {}; - window.twttr.widgets = { - load: transformBlockquotes, - loaded: true, - }; })(); From fe8f482d5c6edf410ddffeff2697395918af420d Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Aug 2026 20:17:14 +0700 Subject: [PATCH 283/309] Add video-only embed support and clean up widgets.js --- public/js/embedResize.js | 2 +- public/js/widgets.js | 203 +++++++++++++++++++------------------- src/sass/tweet/embed.scss | 14 ++- src/views/general.nim | 2 +- 4 files changed, 112 insertions(+), 109 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 486cbcb..69829fe 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -6,7 +6,7 @@ function sendHeight() { var currentHeight = embedElement.offsetHeight; - if (currentHeight !== lastHeight) { + if (currentHeight !== lastHeight && currentHeight > 0) { lastHeight = currentHeight; window.parent.postMessage(['resizeIframe', { h: currentHeight, url: location.href }], '*'); } diff --git a/public/js/widgets.js b/public/js/widgets.js index 5c8bc9c..24345ae 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -1,58 +1,74 @@ /** * Drop-in replacement for Twitter's widgets.js - * Converts twitter-tweet/twitter-video blockquotes to Nitter embeds - * - * Usage: LibRedirect can redirect platform.twitter.com/widgets.js to nitter.net/widgets.js + * Redirects twitter-tweet blockquotes to Nitter embeds */ (function () { "use strict"; - // Idempotent - only run once if (window.__nitterWidgets) return; window.__nitterWidgets = true; - // Determine Nitter instance URL from script src, or fall back to current origin var scripts = document.querySelectorAll('script[src*="widgets.js"]'); var NITTER = scripts.length ? new URL(scripts[scripts.length - 1].src).origin : location.origin; - var TWEET_PATTERN = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; + var TWEET_RE = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video"; - // Ready callback queue var readyCallbacks = []; + var eventCallbacks = {}; var isReady = false; + function safeCall(fn, arg) { + try { fn(arg); } catch (e) {} + } + + function fireEvent(name, data) { + (eventCallbacks[name] || []).forEach(function (cb) { safeCall(cb, data); }); + } + function parseTweetUrl(url) { if (!url) return null; - var match = TWEET_PATTERN.exec(url); - if (match) return { username: match[1], id: match[2] }; - // Fallback: extract any large number (tweet ID) - var idMatch = url.match(/(\d{15,})/); - return idMatch ? { username: null, id: idMatch[1] } : null; + var m = TWEET_RE.exec(url); + if (m) return { user: m[1], id: m[2] }; + m = url.match(/(\d{15,})/); + return m ? { user: null, id: m[1] } : null; } - function createIframe(tweet, options) { - var embedUrl = tweet.username - ? NITTER + "/" + tweet.username + "/status/" + tweet.id + "/embed" - : NITTER + "/i/status/" + tweet.id + "/embed"; + function createIframe(tweet, opts) { + var url; + if (opts.videoOnly) { + url = NITTER + "/i/videos/tweet/" + tweet.id; + } else { + var path = tweet.user ? "/" + tweet.user : "/i"; + url = NITTER + path + "/status/" + tweet.id + "/embed"; + if (opts.theme) { + var theme = opts.theme === "dark" ? "nitter" : + opts.theme === "light" ? "twitter" : opts.theme; + url += "?theme=" + encodeURIComponent(theme); + } + } var iframe = document.createElement("iframe"); - iframe.src = embedUrl; + iframe.src = url; iframe.className = "nitter-embed-frame"; + iframe.loading = "lazy"; iframe.setAttribute("allowtransparency", "true"); iframe.setAttribute("frameborder", "0"); iframe.setAttribute("scrolling", "no"); - iframe.loading = "lazy"; + if (opts.videoOnly) iframe.setAttribute("allowfullscreen", "true"); - // Styling with data attribute support - var width = options.width || "550"; - var align = options.align || "center"; - var margin = align === "center" ? "10px auto" : - align === "right" ? "10px 0 10px auto" : "10px auto 10px 0"; + var width = opts.width || 550; + var margin = opts.align === "center" ? "10px auto" : + opts.align === "right" ? "10px 0 10px auto" : "10px 0"; + iframe.style.cssText = + "width:100%;max-width:" + width + "px;height:250px;" + + "border:none;display:block;margin:" + margin; - iframe.style.cssText = "width:100%;max-width:" + width + "px;height:250px;border:none;display:block;margin:" + margin; + iframe.addEventListener("load", function () { + fireEvent("rendered", { target: iframe }); + }); return iframe; } @@ -61,59 +77,41 @@ if (bq.dataset.nitterProcessed) return false; bq.dataset.nitterProcessed = "true"; - // Find tweet URL in links var tweet = null; var links = bq.querySelectorAll("a[href]"); - for (var i = 0; i < links.length; i++) { + for (var i = 0; i < links.length && !tweet; i++) { tweet = parseTweetUrl(links[i].href); - if (tweet) break; } + if (!tweet) return false; - if (!tweet) { - console.warn("[Nitter widgets.js] No tweet URL found in blockquote"); - return false; - } + var d = bq.dataset; + var iframe = createIframe(tweet, { + width: d.mediaMaxWidth || d.width, + align: d.align, + theme: d.theme, + videoOnly: d.mediaMaxWidth !== undefined + }); - // Read Twitter's data attributes - var options = { - width: bq.dataset.width, - align: bq.dataset.align, - theme: bq.dataset.theme - }; - - var iframe = createIframe(tweet, options); - - // Hide original (keep as fallback), insert iframe after bq.style.display = "none"; bq.parentNode.insertBefore(iframe, bq.nextSibling); return true; } - function processEmbeds(container) { - var root = container || document; - var blockquotes = root.querySelectorAll(SELECTOR + ":not([data-nitter-processed])"); - var count = 0; - - for (var i = 0; i < blockquotes.length; i++) { - if (processBlockquote(blockquotes[i])) count++; - } - - if (count > 0) { - console.log("[Nitter widgets.js] Processed " + count + " embed(s)"); - } + function processEmbeds(root) { + var bqs = (root || document).querySelectorAll(SELECTOR + ":not([data-nitter-processed])"); + for (var i = 0; i < bqs.length; i++) processBlockquote(bqs[i]); } - function handleResize(event) { - if (!Array.isArray(event.data) || event.data[0] !== "resizeIframe") return; - var h = event.data[1] && event.data[1].h; + function handleResize(e) { + if (!Array.isArray(e.data) || e.data[0] !== "resizeIframe") return; + var h = e.data[1] && e.data[1].h; if (!h || h <= 0) return; - // Find iframe by matching contentWindow - var iframes = document.querySelectorAll("iframe.nitter-embed-frame"); - for (var i = 0; i < iframes.length; i++) { - if (iframes[i].contentWindow === event.source) { - iframes[i].style.height = h + "px"; - break; + var frames = document.querySelectorAll("iframe.nitter-embed-frame"); + for (var i = 0; i < frames.length; i++) { + if (frames[i].contentWindow === e.source) { + frames[i].style.height = h + "px"; + return; } } } @@ -121,43 +119,49 @@ function observeDOM() { if (!window.MutationObserver || !document.body) return; - new MutationObserver(function (mutations) { - for (var i = 0; i < mutations.length; i++) { - var nodes = mutations[i].addedNodes; - for (var j = 0; j < nodes.length; j++) { - var node = nodes[j]; - if (node.nodeType !== 1) continue; - if ((node.matches && node.matches(SELECTOR)) || - (node.querySelector && node.querySelector(SELECTOR))) { - processEmbeds(); - return; - } - } - } + function matches(el) { + return el.matches(SELECTOR) || el.querySelector(SELECTOR); + } + + new MutationObserver(function (muts) { + var found = muts.some(function (mut) { + return Array.prototype.some.call(mut.addedNodes, function (n) { + return n.nodeType === 1 && matches(n); + }); + }); + if (found) processEmbeds(); }).observe(document.body, { childList: true, subtree: true }); } - function fireReady() { - isReady = true; - for (var i = 0; i < readyCallbacks.length; i++) { - try { readyCallbacks[i](window.twttr); } catch (e) {} - } - readyCallbacks = []; + function embedTweet(id, container, opts) { + if (!container) return Promise.reject("No container"); + var iframe = createIframe({ id: id, user: null }, opts || {}); + container.appendChild(iframe); + return Promise.resolve(iframe); } - // Expose twttr API for compatibility var prevTwttr = window.twttr; window.twttr = { widgets: { - load: function (el) { processEmbeds(el); }, - createTweet: function (id, container, opts) { - if (!container) return Promise.reject("No container"); - var iframe = createIframe({ id: id, username: null }, opts || {}); - container.appendChild(iframe); - return Promise.resolve(iframe); - }, + load: processEmbeds, + createTweet: embedTweet, + createTweetEmbed: embedTweet, + createVideo: embedTweet, loaded: true }, + events: { + bind: function (name, cb) { + if (typeof cb !== "function") return; + if (!eventCallbacks[name]) eventCallbacks[name] = []; + eventCallbacks[name].push(cb); + }, + unbind: function (name, cb) { + if (!eventCallbacks[name]) return; + eventCallbacks[name] = cb + ? eventCallbacks[name].filter(function (f) { return f !== cb; }) + : []; + } + }, ready: function (cb) { if (typeof cb !== "function") return; if (isReady) cb(window.twttr); @@ -166,27 +170,22 @@ _e: [] }; - // Process any callbacks queued before we loaded (twttr._e pattern) + // Process callbacks queued before load (twttr._e pattern) if (prevTwttr && prevTwttr._e) { - for (var i = 0; i < prevTwttr._e.length; i++) { - try { prevTwttr._e[i](); } catch (e) {} - } + prevTwttr._e.forEach(function (cb) { safeCall(cb); }); } - // Remove Twitter scripts that might have snuck in - var twitterScripts = document.querySelectorAll( - 'script[src*="platform.twitter.com"], script[src*="platform.x.com"]' - ); - for (var i = 0; i < twitterScripts.length; i++) { - twitterScripts[i].remove(); - } + // Remove any Twitter scripts that snuck through + document.querySelectorAll('script[src*="platform.twitter.com"], script[src*="platform.x.com"]') + .forEach(function (s) { s.remove(); }); - // Initialize function init() { window.addEventListener("message", handleResize); processEmbeds(); observeDOM(); - fireReady(); + isReady = true; + readyCallbacks.forEach(function (cb) { safeCall(cb, window.twttr); }); + readyCallbacks = []; } if (document.readyState === "loading") { diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index bee23d3..31370e9 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -2,16 +2,20 @@ @import "_mixins"; .embed-video { + width: 100%; + background-color: black; + .gallery-video { width: 100%; - height: 100%; - position: absolute; - background-color: black; - top: 0%; - left: 0%; } .gallery-video > .attachment { max-height: unset; + width: 100%; + } + + video { + width: 100%; + height: auto; } } diff --git a/src/views/general.nim b/src/views/general.nim index d085ad5..77e8402 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=84") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=85") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: From ca19250a62a2cabb4c6700da18b5164e6ef2fd3f Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Aug 2026 20:27:38 +0700 Subject: [PATCH 284/309] Remove embedTweet.js, use instead --- public/js/embedTweet.js | 28 ---------------------------- src/views/embed.nim | 11 +++++------ 2 files changed, 5 insertions(+), 34 deletions(-) delete mode 100644 public/js/embedTweet.js diff --git a/public/js/embedTweet.js b/public/js/embedTweet.js deleted file mode 100644 index 166f253..0000000 --- a/public/js/embedTweet.js +++ /dev/null @@ -1,28 +0,0 @@ -// This runs after embedResize.js sets up the sendHeight function -(function() { - var sendHeight = window._nitterSendHeight; - - // Make images load eagerly so height updates correctly - var lazyImages = document.querySelectorAll('img[loading="lazy"]'); - for (var i = 0; i < lazyImages.length; i++) { - lazyImages[i].loading = 'eager'; - } - - // Update height when images finish loading (only if sendHeight available) - if (sendHeight) { - var allImages = document.querySelectorAll('img'); - for (var i = 0; i < allImages.length; i++) { - var img = allImages[i]; - if (!img.complete) { - img.addEventListener('load', sendHeight); - } - } - } - - // Open all links in new tab (we're in an iframe) - var allLinks = document.querySelectorAll('a'); - for (var i = 0; i < allLinks.length; i++) { - allLinks[i].target = '_blank'; - } - -})(); diff --git a/src/views/embed.nim b/src/views/embed.nim index f52dd15..fc5abdf 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -8,8 +8,6 @@ import general, tweet const doctype = "\n" embedResizeJs = staticRead("../../public/js/embedResize.js") - embedTweetJs = embedResizeJs & ";" & staticRead("../../public/js/embedTweet.js") - embedErrorJs = embedResizeJs & ";if(window._nitterSendHeight)requestAnimationFrame(window._nitterSendHeight);" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = let @@ -33,17 +31,18 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req) + base(target="_blank") body: tdiv(class="embed-wrapper"): tdiv(class="tweet-embed"): - a(class="tweet-link", href=getLink(tweet), target="_blank") + a(class="tweet-link", href=getLink(tweet)) renderTweet(tweet, prefs, path, mainTweet=true) - a(class="embed-footer", href=getLink(tweet), target="_blank"): + a(class="embed-footer", href=getLink(tweet)): text "Read more on " & cfg.hostname script: - verbatim embedTweetJs + verbatim embedResizeJs result = doctype & $node @@ -57,6 +56,6 @@ proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request): span: text error script: - verbatim embedErrorJs + verbatim embedResizeJs result = doctype & $node From 67f620c83ccdfdfe2ffd2bab9aa172c9c7211c67 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Aug 2026 22:51:12 +0700 Subject: [PATCH 285/309] Improve video and error embeds --- src/routes/embed.nim | 15 +++-- src/sass/tweet/_base.scss | 103 ----------------------------- src/sass/tweet/embed.scss | 133 +++++++++++++++++++++++++++++++++++++- src/views/embed.nim | 23 +++++-- src/views/general.nim | 2 +- 5 files changed, 161 insertions(+), 115 deletions(-) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 5c45f29..5f3e6f3 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -32,25 +32,30 @@ proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": let - tweet = await getTweetByRestId(@"id") + id = @"id" + tweet = await getTweetByRestId(id) prefs = requestPrefs() if tweet == nil: - resp renderErrorEmbed("Tweet not found", prefs, cfg, request) + resp renderErrorEmbed("Tweet not found", prefs, cfg, request, tweetId=id) if not tweet.hasVideos: - resp renderErrorEmbed("No video in tweet", prefs, cfg, request) + resp renderErrorEmbed("No video in tweet", prefs, cfg, request, + tweetId=id, username=tweet.user.username) resp renderVideoEmbed(tweet, cfg, request) get "/@user/status/@id/embed": let - tweet = await getTweetByRestId(@"id") + id = @"id" + user = @"user" + tweet = await getTweetByRestId(id) prefs = requestPrefs() path = getPath() if tweet == nil: - resp renderErrorEmbed("Tweet not found", prefs, cfg, request) + resp renderErrorEmbed("Tweet not found", prefs, cfg, request, + tweetId=id, username=user) resp renderTweetEmbed(tweet, path, prefs, cfg, request) diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 050eaad..2f6693e 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -117,109 +117,6 @@ } } -body:has(> .embed-wrapper), -body:has(> .tweet-embed) { - margin: 0; - padding: 0; - width: 100%; - background: transparent; - overflow: hidden; -} - -html:has(body > .embed-wrapper), -html:has(body > .tweet-embed) { - background: transparent; - overflow: hidden; -} - -.embed-wrapper { - width: 100%; - box-sizing: border-box; - border: 1px solid var(--border_grey); - border-radius: 12px; - overflow: hidden; - - .embed-footer { - display: block; - padding: 12px 16px; - border-top: 1px solid var(--border_grey); - background: var(--bg_panel); - color: var(--accent); - font-size: 14px; - font-weight: 500; - text-align: center; - text-decoration: none; - transition: background-color 0.15s; - - &:hover { - background: var(--bg_hover); - } - } -} - -.tweet-embed { - position: relative; - width: 100%; - box-sizing: border-box; - background-color: var(--bg_panel); - transition: background-color 0.15s ease; - - &:hover { - background-color: var(--bg_hover); - } - - .timeline-item { - display: block; - pointer-events: none; - background-color: transparent; - } - - .tweet-link:hover { - background-color: transparent; - } - - .tweet-content { - font-size: 18px; - } - - .avatar:not(.mini) { - position: absolute; - } - - .quote-media-container { - max-height: 600px; - } - - .quote-media-container .gallery-row .attachment, - .quote-media-container .gallery-row .attachment > video, - .quote-media-container .gallery-row .attachment > img, - .quote-media-container .still-image, - .quote-media-container .still-image img, - .still-image, - .still-image img { - max-height: 600px; - } - - &.error-embed { - display: flex; - align-items: center; - justify-content: center; - min-height: 160px; - padding: 20px; - border: 1px solid var(--border_grey); - border-radius: 12px; - cursor: default; - - &:hover { - background-color: var(--bg_panel); - } - - .error-panel { - margin: 0; - } - } -} - .attribution { display: flex; pointer-events: all; diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 31370e9..9ca1d48 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -1,21 +1,150 @@ @import "_variables"; @import "_mixins"; +// Embed page: transparent background, no scrollbars +html:has(body > .embed-wrapper), +html:has(body > .embed-video) { + background: transparent; + overflow: hidden; + + body { + background: transparent; + overflow: hidden; + } +} + +// Tweet embed wrapper +.embed-wrapper { + box-sizing: border-box; + border: 1px solid var(--border_grey); + border-radius: 12px; + overflow: hidden; + + .embed-footer { + display: block; + padding: 12px 16px; + border-top: 1px solid var(--border_grey); + background: var(--bg_panel); + color: var(--accent); + font-size: 14px; + font-weight: 500; + text-align: center; + text-decoration: none; + transition: background-color 0.15s; + + &:hover { + background: var(--bg_hover); + } + } +} + +// Tweet embed content +.tweet-embed { + position: relative; + background-color: var(--bg_panel); + transition: background-color 0.15s; + + &:hover { + background-color: var(--bg_hover); + } + + .timeline-item { + pointer-events: none; + background-color: transparent; + } + + .tweet-link:hover { + background-color: transparent; + } + + .tweet-content { + font-size: 18px; + } + + .avatar:not(.mini) { + position: absolute; + } + + // Cap media height in embeds + .still-image img, + .quote-media-container img, + .quote-media-container video { + max-height: 600px; + } + + &.error-embed { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + padding: 20px; + + .error-panel { + margin: 0; + } + } +} + +// Video-only embed .embed-video { - width: 100%; + position: relative; background-color: black; + border: 1px solid var(--border_grey); + + .attachments { + margin: 0; + border-radius: 0; + max-height: 560px; + background-color: unset; + } + + .card { + margin: 0; + } .gallery-video { width: 100%; } .gallery-video > .attachment { - max-height: unset; + max-height: 560px; width: 100%; } video { width: 100%; height: auto; + max-height: 560px; + object-fit: contain; + } + + .video-download { + display: none; + } + + .video-overlay-link { + position: absolute; + top: 12px; + right: 12px; + padding: 6px 12px; + background: rgba(30, 30, 30, 0.75); + backdrop-filter: blur(4px); + color: #fff; + font-size: 13px; + font-weight: 700; + text-decoration: none; + border-radius: 9999px; + border: 1px solid transparent; + transition: background 0.15s, opacity 0.15s; + z-index: 10; + + &:hover { + background: rgba(60, 60, 60, 0.9); + } + } + + &:has(video:playing) .video-overlay-link { + opacity: 0; + pointer-events: none; } } diff --git a/src/views/embed.nim b/src/views/embed.nim index fc5abdf..27fda24 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -15,13 +15,17 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = thumb = video.thumb vidUrl = getVideoEmbed(cfg, tweet.id) prefs = Prefs(hlsPlayback: true, mp4Playback: true) + tweetUrl = getLink(tweet) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) + base(target="_blank") body: tdiv(class="embed-video"): renderVideo(video, prefs, "") + a(class="video-overlay-link", href=tweetUrl): + text "Watch on " & cfg.hostname script: verbatim embedResizeJs @@ -46,14 +50,25 @@ proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; re result = doctype & $node -proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request): string = +proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request; + tweetId = ""; username = ""): string = + let link = if tweetId.len > 0: + if username.len > 0: "/" & username & "/status/" & tweetId + else: "/i/status/" & tweetId + else: "/" + let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req) + base(target="_blank") body: - tdiv(class="tweet-embed error-embed"): - tdiv(class="error-panel"): - span: text error + tdiv(class="embed-wrapper"): + tdiv(class="tweet-embed error-embed"): + a(class="tweet-link", href=link) + tdiv(class="error-panel"): + span: text error + a(class="embed-footer", href=link): + text "Read more on " & cfg.hostname script: verbatim embedResizeJs diff --git a/src/views/general.nim b/src/views/general.nim index 77e8402..331c1f2 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=85") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=101") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: From b76e3c3913a15c0f4ab433d9c4d575ad818ab482 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 10 Aug 2026 22:51:17 +0700 Subject: [PATCH 286/309] Cap embed iframe height at 10000px --- public/js/widgets.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/widgets.js b/public/js/widgets.js index 24345ae..4b6bf49 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -105,7 +105,7 @@ function handleResize(e) { if (!Array.isArray(e.data) || e.data[0] !== "resizeIframe") return; var h = e.data[1] && e.data[1].h; - if (!h || h <= 0) return; + if (!h || h <= 0 || h > 10000) return; // Cap at 10000px for sanity var frames = document.querySelectorAll("iframe.nitter-embed-frame"); for (var i = 0; i < frames.length; i++) { From 5fa4eefb9d60fdfbc4c54b27197b0575438c0053 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 12 Aug 2026 01:36:40 +0700 Subject: [PATCH 287/309] Improve video embed overlay and proxy behavior --- public/js/embedResize.js | 26 ++++++++++++--- public/js/widgets.js | 69 ++++++++++++++++++++++++++------------- src/sass/tweet/embed.scss | 15 +++++++-- src/views/embed.nim | 2 +- src/views/general.nim | 2 +- 5 files changed, 82 insertions(+), 32 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 69829fe..664abbf 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -1,25 +1,41 @@ -(function() { - var embedElement = document.querySelector('.embed-wrapper, .tweet-embed, .embed-video'); +(function () { + var embedElement = document.querySelector( + ".embed-wrapper, .tweet-embed, .embed-video", + ); if (!embedElement) return; + // Video play state for overlay (hidden while playing, visible on hover/pause) + var video = embedElement.querySelector("video"); + if (video) { + video.onplay = function () { + embedElement.classList.add("video-playing"); + }; + video.onpause = video.onended = function () { + embedElement.classList.remove("video-playing"); + }; + } + var lastHeight = 0; function sendHeight() { var currentHeight = embedElement.offsetHeight; if (currentHeight !== lastHeight && currentHeight > 0) { lastHeight = currentHeight; - window.parent.postMessage(['resizeIframe', { h: currentHeight, url: location.href }], '*'); + window.parent.postMessage( + ["resizeIframe", { h: currentHeight, url: location.href }], + "*", + ); } } // Respond to height requests from parent via MessageChannel - window.addEventListener('message', function(event) { + window.addEventListener("message", function (event) { if (event.source === window.parent && event.ports && event.ports[0]) { event.ports[0].postMessage(embedElement.offsetHeight); } }); - window.addEventListener('load', sendHeight); + window.addEventListener("load", sendHeight); new ResizeObserver(sendHeight).observe(embedElement); // Expose for embedTweet.js diff --git a/public/js/widgets.js b/public/js/widgets.js index 4b6bf49..7bb283a 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -8,10 +8,7 @@ if (window.__nitterWidgets) return; window.__nitterWidgets = true; - var scripts = document.querySelectorAll('script[src*="widgets.js"]'); - var NITTER = scripts.length - ? new URL(scripts[scripts.length - 1].src).origin - : location.origin; + var NITTER = new URL(document.currentScript.src).origin; var TWEET_RE = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video"; @@ -21,11 +18,15 @@ var isReady = false; function safeCall(fn, arg) { - try { fn(arg); } catch (e) {} + try { + fn(arg); + } catch (e) { } } function fireEvent(name, data) { - (eventCallbacks[name] || []).forEach(function (cb) { safeCall(cb, data); }); + (eventCallbacks[name] || []).forEach(function (cb) { + safeCall(cb, data); + }); } function parseTweetUrl(url) { @@ -44,8 +45,12 @@ var path = tweet.user ? "/" + tweet.user : "/i"; url = NITTER + path + "/status/" + tweet.id + "/embed"; if (opts.theme) { - var theme = opts.theme === "dark" ? "nitter" : - opts.theme === "light" ? "twitter" : opts.theme; + var theme = + opts.theme === "dark" + ? "nitter" + : opts.theme === "light" + ? "twitter" + : opts.theme; url += "?theme=" + encodeURIComponent(theme); } } @@ -60,11 +65,18 @@ if (opts.videoOnly) iframe.setAttribute("allowfullscreen", "true"); var width = opts.width || 550; - var margin = opts.align === "center" ? "10px auto" : - opts.align === "right" ? "10px 0 10px auto" : "10px 0"; + var margin = + opts.align === "center" + ? "10px auto" + : opts.align === "right" + ? "10px 0 10px auto" + : "10px 0"; iframe.style.cssText = - "width:100%;max-width:" + width + "px;height:250px;" + - "border:none;display:block;margin:" + margin; + "width:100%;max-width:" + + width + + "px;height:300px;" + + "border:none;display:block;margin:" + + margin; iframe.addEventListener("load", function () { fireEvent("rendered", { target: iframe }); @@ -89,7 +101,7 @@ width: d.mediaMaxWidth || d.width, align: d.align, theme: d.theme, - videoOnly: d.mediaMaxWidth !== undefined + videoOnly: d.mediaMaxWidth !== undefined, }); bq.style.display = "none"; @@ -98,7 +110,9 @@ } function processEmbeds(root) { - var bqs = (root || document).querySelectorAll(SELECTOR + ":not([data-nitter-processed])"); + var bqs = (root || document).querySelectorAll( + SELECTOR + ":not([data-nitter-processed])", + ); for (var i = 0; i < bqs.length; i++) processBlockquote(bqs[i]); } @@ -147,7 +161,7 @@ createTweet: embedTweet, createTweetEmbed: embedTweet, createVideo: embedTweet, - loaded: true + loaded: true, }, events: { bind: function (name, cb) { @@ -158,33 +172,44 @@ unbind: function (name, cb) { if (!eventCallbacks[name]) return; eventCallbacks[name] = cb - ? eventCallbacks[name].filter(function (f) { return f !== cb; }) + ? eventCallbacks[name].filter(function (f) { + return f !== cb; + }) : []; - } + }, }, ready: function (cb) { if (typeof cb !== "function") return; if (isReady) cb(window.twttr); else readyCallbacks.push(cb); }, - _e: [] + _e: [], }; // Process callbacks queued before load (twttr._e pattern) if (prevTwttr && prevTwttr._e) { - prevTwttr._e.forEach(function (cb) { safeCall(cb); }); + prevTwttr._e.forEach(function (cb) { + safeCall(cb); + }); } // Remove any Twitter scripts that snuck through - document.querySelectorAll('script[src*="platform.twitter.com"], script[src*="platform.x.com"]') - .forEach(function (s) { s.remove(); }); + document + .querySelectorAll( + 'script[src*="platform.twitter.com"], script[src*="platform.x.com"]', + ) + .forEach(function (s) { + s.remove(); + }); function init() { window.addEventListener("message", handleResize); processEmbeds(); observeDOM(); isReady = true; - readyCallbacks.forEach(function (cb) { safeCall(cb, window.twttr); }); + readyCallbacks.forEach(function (cb) { + safeCall(cb, window.twttr); + }); readyCallbacks = []; } diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 9ca1d48..9ff8403 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -88,6 +88,7 @@ html:has(body > .embed-video) { // Video-only embed .embed-video { position: relative; + min-height: 300px; background-color: black; border: 1px solid var(--border_grey); @@ -106,7 +107,7 @@ html:has(body > .embed-video) { width: 100%; } - .gallery-video > .attachment { + .gallery-video>.attachment { max-height: 560px; width: 100%; } @@ -135,7 +136,9 @@ html:has(body > .embed-video) { text-decoration: none; border-radius: 9999px; border: 1px solid transparent; - transition: background 0.15s, opacity 0.15s; + transition: + background 0.15s, + opacity 0.15s; z-index: 10; &:hover { @@ -143,8 +146,14 @@ html:has(body > .embed-video) { } } - &:has(video:playing) .video-overlay-link { + // Hide button while playing, show on hover or when paused + &.video-playing .video-overlay-link { opacity: 0; pointer-events: none; } + + &.video-playing:hover .video-overlay-link { + opacity: 1; + pointer-events: auto; + } } diff --git a/src/views/embed.nim b/src/views/embed.nim index 27fda24..82ef670 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -14,7 +14,7 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = video = tweet.getVideos()[0] thumb = video.thumb vidUrl = getVideoEmbed(cfg, tweet.id) - prefs = Prefs(hlsPlayback: true, mp4Playback: true) + prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: true) tweetUrl = getLink(tweet) let node = buildHtml(html(lang="en")): diff --git a/src/views/general.nim b/src/views/general.nim index 331c1f2..143da54 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=101") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=106") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") if theme.len > 0: From 1d61f8cf070fd7e2a162ee1ecfa3e08a70a3dd9a Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 13 Aug 2026 02:01:59 +0700 Subject: [PATCH 288/309] Respect instance proxyVideos config in video embeds --- src/views/embed.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/embed.nim b/src/views/embed.nim index 82ef670..5136a35 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -2,7 +2,7 @@ import karax/[karaxdsl, vdom] from jester import Request -import ".."/[types, formatters] +import ".."/[types, formatters, prefs] import general, tweet const @@ -14,7 +14,7 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = video = tweet.getVideos()[0] thumb = video.thumb vidUrl = getVideoEmbed(cfg, tweet.id) - prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: true) + prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos) tweetUrl = getLink(tweet) let node = buildHtml(html(lang="en")): From 756beab8a60d4ffd1284288e658484d9047aeb6c Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 13 Aug 2026 02:37:51 +0700 Subject: [PATCH 289/309] Clean up embedResize.js --- public/js/embedResize.js | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/public/js/embedResize.js b/public/js/embedResize.js index 664abbf..3fb05a0 100644 --- a/public/js/embedResize.js +++ b/public/js/embedResize.js @@ -1,43 +1,34 @@ (function () { - var embedElement = document.querySelector( - ".embed-wrapper, .tweet-embed, .embed-video", - ); - if (!embedElement) return; + var embed = document.querySelector(".embed-wrapper, .embed-video"); + if (!embed) return; - // Video play state for overlay (hidden while playing, visible on hover/pause) - var video = embedElement.querySelector("video"); + var video = embed.querySelector("video"); if (video) { video.onplay = function () { - embedElement.classList.add("video-playing"); + embed.classList.add("video-playing"); }; video.onpause = video.onended = function () { - embedElement.classList.remove("video-playing"); + embed.classList.remove("video-playing"); }; } var lastHeight = 0; function sendHeight() { - var currentHeight = embedElement.offsetHeight; - if (currentHeight !== lastHeight && currentHeight > 0) { - lastHeight = currentHeight; - window.parent.postMessage( - ["resizeIframe", { h: currentHeight, url: location.href }], - "*", - ); + var h = embed.offsetHeight; + if (h !== lastHeight && h > 0) { + lastHeight = h; + window.parent.postMessage(["resizeIframe", { h: h }], "*"); } } - // Respond to height requests from parent via MessageChannel - window.addEventListener("message", function (event) { - if (event.source === window.parent && event.ports && event.ports[0]) { - event.ports[0].postMessage(embedElement.offsetHeight); + // MessageChannel height request (used by oEmbed) + window.addEventListener("message", function (e) { + if (e.source === window.parent && e.ports && e.ports[0]) { + e.ports[0].postMessage(embed.offsetHeight); } }); window.addEventListener("load", sendHeight); - new ResizeObserver(sendHeight).observe(embedElement); - - // Expose for embedTweet.js - window._nitterSendHeight = sendHeight; + new ResizeObserver(sendHeight).observe(embed); })(); From 989b1f5f6490a0b35a817e1374c9db7b9b82cc83 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 13 Aug 2026 22:43:59 +0700 Subject: [PATCH 290/309] Fix endless test reruns --- .github/workflows/run-tests.yml | 3 ++- tests/pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index dcfff52..daccac9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -62,6 +62,7 @@ jobs: needs: [build-test] name: Integration test runs-on: ubuntu-24.04 + timeout-minutes: 30 services: redis: @@ -147,4 +148,4 @@ jobs: run: | ./nitter & cd tests - poetry run pytest -n3 --reruns=5 --rs . + poetry run pytest -n3 --rs . diff --git a/tests/pyproject.toml b/tests/pyproject.toml index a0def28..4105166 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--pls=eager --reruns=5 --timeout_multiplier=5" +addopts = "--pls=eager --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --timeout_multiplier=5" From 7f6c083dce7a5482a5257c9a9e0b719189f19fcb Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 14 Aug 2026 02:27:05 +0700 Subject: [PATCH 291/309] Add oEmbed discovery and standard support Fixes #696 --- src/routes/embed.nim | 68 +++++++++++++++++++++++++++++------------ src/routes/status.nim | 6 +++- src/views/general.nim | 10 ++++-- src/views/oembed.nimf | 4 +-- tests/pyproject.toml | 2 +- tests/test_embed.py | 71 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 26 deletions(-) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 5f3e6f3..d1531e4 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -8,24 +8,32 @@ import router_utils export api, embed, vdom, tweet, general, router_utils -proc parseTweetUrl*(url: string): tuple[username, id: string] = +proc parseTweetPath(path: string): tuple[username, id: string] = + let parts = path.split('/') + if parts.len >= 3 and parts[1] in ["status", "statuses"]: + let tweetId = parts[2].split('?')[0].split('#')[0] + if tweetId.len > 0 and tweetId.allCharsInSet(Digits): + return (parts[0], tweetId) + return ("", "") + +proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] = var path = url if path.startsWith("https://"): path = path[8..^1] elif path.startsWith("http://"): path = path[7..^1] - const prefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", - "www.twitter.com/", "www.x.com/"] - for prefix in prefixes: + const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", + "www.twitter.com/", "www.x.com/"] + + for prefix in twitterPrefixes: if path.startsWith(prefix): - path = path[prefix.len..^1] - let parts = path.split('/') - if parts.len >= 3 and parts[1] == "status": - let tweetId = parts[2].split('?')[0].split('#')[0] - if tweetId.len > 0 and tweetId.allCharsInSet(Digits): - return (parts[0], tweetId) - break + return parseTweetPath(path[prefix.len..^1]) + + let nitterPrefix = cfg.hostname & "/" + if path.startsWith(nitterPrefix): + return parseTweetPath(path[nitterPrefix.len..^1]) + return ("", "") proc createEmbedRouter*(cfg: Config) = @@ -68,11 +76,19 @@ proc createEmbedRouter*(cfg: Config) = resp Http404 get "/api/oembed": - let url = @"url" + responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) + + let + url = @"url" + format = @"format" + + if format.len > 0 and format != "json": + resp Http501, "Only JSON format is supported" + if url.len == 0: resp Http400, "Missing url parameter" - let (username, tweetId) = parseTweetUrl(url) + let (username, tweetId) = parseTweetUrl(url, cfg) if username.len == 0 or tweetId.len == 0: resp Http400, "Invalid tweet URL" @@ -81,20 +97,34 @@ proc createEmbedRouter*(cfg: Config) = resp Http404 let - embedUrl = getUrlPrefix(cfg) & "/" & username & "/status/" & tweetId & "/embed" + maxwidthParam = @"maxwidth" + maxwidth = if maxwidthParam.len > 0: + try: clamp(parseInt(maxwidthParam), 220, 550) + except ValueError: 550 + else: 550 + embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed" authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username + title = stripHtml(tweet.text) - responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) - respJson %*{ + var response = %*{ "version": "1.0", "type": "rich", "provider_name": cfg.title, "provider_url": getUrlPrefix(cfg), + "title": title, "author_name": tweet.user.fullname, "author_url": authorUrl, "url": embedUrl, - "width": 550, - "height": nil, + "width": maxwidth, + "height": newJNull(), "cache_age": "3153600000", - "html": renderOembedIframe(embedUrl) + "html": renderOembedIframe(embedUrl, maxwidth) } + + if tweet.media.len > 0: + let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb) + response["thumbnail_url"] = %thumbUrl + response["thumbnail_width"] = %maxwidth + response["thumbnail_height"] = %maxwidth + + respJson response diff --git a/src/routes/status.nim b/src/routes/status.nim index 870b6d7..32a4447 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -66,9 +66,13 @@ proc createStatusRouter*(cfg: Config) = elif card.video.isSome(): images = @[card.video.get().thumb] + let + tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id + oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl) + let html = renderConversation(conv, prefs, getPath() & "#m", sort) resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, - images=images, video=video) + images=images, video=video, oembed=oembedUrl) get "/@name/status/@id/history/?": cond '.' notin @"name" diff --git a/src/views/general.nim b/src/views/general.nim index 143da54..d979898 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -38,7 +38,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; - rss=""; alternate=""): VNode = + rss=""; alternate=""; oembed=""): VNode = let theme = prefs.theme.toTheme let ogType = @@ -70,6 +70,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") + if oembed.len > 0: + let oembedTitle = if titleText.len > 0: titleText else: "oEmbed" + link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle) + if prefs.hlsPlayback: script(src="/js/hls.min.js", `defer`="") script(src="/js/hlsPlayback.js?v=1", `defer`="") @@ -124,7 +128,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; images: seq[string] = @[]; banner=""; - twitterLink=""): string = + twitterLink=""; oembed=""): string = let twitterLink = if twitterLink.len > 0: twitterLink @@ -132,7 +136,7 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, twitterLink) + rss, twitterLink, oembed) let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" body(class=bodyClass): diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf index 3dc793b..4f7f947 100644 --- a/src/views/oembed.nimf +++ b/src/views/oembed.nimf @@ -1,7 +1,7 @@ #? stdtmpl(subsChar = '$', metaChar = '#') ## SPDX-License-Identifier: AGPL-3.0-only -#proc renderOembedIframe*(embedUrl: string): string = +#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string = # result = "" - + # result = result.strip() #end proc diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 4105166..1907e60 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--pls=eager --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --timeout_multiplier=5" +addopts = "--pls=eager --rcs --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --only-rerun=WebDriverException --timeout_multiplier=5" diff --git a/tests/test_embed.py b/tests/test_embed.py index c15d247..77b69e6 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -178,12 +178,83 @@ class OEmbedApiTest(BaseTestCase): resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=400') self.assertEqual(resp.status_code, 200) + def test_oembed_maxwidth_clamps_to_range(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=100') + data = resp.json() + self.assertEqual(data['width'], 220) + self.assertIn('max-width:220px', data['html']) + + def test_oembed_maxwidth_caps_at_550(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=9999') + data = resp.json() + self.assertEqual(data['width'], 550) + + def test_oembed_maxwidth_invalid_uses_default(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=abc') + data = resp.json() + self.assertEqual(data['width'], 550) + def test_oembed_author_url_present(self): resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') data = resp.json() self.assertIn('author_url', data) self.assertIn('elonmusk', data['author_url']) + def test_oembed_accepts_nitter_url(self): + nitter_url = f'{self.base_url}/elonmusk/status/1141367104702038016' + resp = requests.get(f'{self.base_url}/api/oembed?url={nitter_url}') + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIn('html', data) + + def test_oembed_format_json_accepted(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&format=json') + self.assertEqual(resp.status_code, 200) + + def test_oembed_format_xml_returns_501(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&format=xml') + self.assertEqual(resp.status_code, 501) + + def test_oembed_has_title(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIn('title', data) + self.assertIsInstance(data['title'], str) + self.assertGreater(len(data['title']), 0) + + def test_oembed_has_null_height(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIsNone(data['height']) + + +class OEmbedDiscoveryTest(BaseTestCase): + """Test oEmbed discovery link tags on tweet pages.""" + + def test_tweet_page_has_oembed_link_tag(self): + self.open_nitter('elonmusk/status/1141367104702038016') + self.assert_element_present('link[type="application/json+oembed"]') + + def test_oembed_link_tag_points_to_api(self): + resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + self.assertIn('application/json+oembed', resp.text) + self.assertIn('/api/oembed?url=', resp.text) + self.assertIn('1141367104702038016', resp.text) + + def test_oembed_discovery_roundtrip(self): + """Fetch a tweet page, extract oEmbed URL, call it, verify response.""" + import re + resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + match = re.search( + r'type="application/json\+oembed"\s+href="([^"]*)"', resp.text) + self.assertIsNotNone(match, "No oEmbed discovery link found in page") + oembed_url = match.group(1).replace('&', '&') + oembed_resp = requests.get(oembed_url) + self.assertEqual(oembed_resp.status_code, 200) + data = oembed_resp.json() + self.assertEqual(data['type'], 'rich') + self.assertIn('html', data) + class VideoEmbedTest(BaseTestCase): """Test video embed route (/i/videos/tweet/{id}).""" From 06e59f06159381b6df9ce107553470a78eee7bd2 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 15 Aug 2026 00:23:53 +0700 Subject: [PATCH 292/309] Fix oEmbed CI: accept any host in URL parsing --- src/routes/embed.nim | 10 ++++++++++ tests/test_embed.py | 9 +++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index d1531e4..24bba2d 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -34,6 +34,16 @@ proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] = if path.startsWith(nitterPrefix): return parseTweetPath(path[nitterPrefix.len..^1]) + # Fall back: strip any hostname and try to parse as a tweet path. + # Handles requests where the URL's host differs from cfg.hostname + # (e.g. localhost in dev/CI, or a reverse proxy with a different domain). + let slashPos = path.find('/') + if slashPos > 0: + let afterHost = path[slashPos + 1..^1] + let parsed = parseTweetPath(afterHost) + if parsed.username.len > 0: + return parsed + return ("", "") proc createEmbedRouter*(cfg: Config) = diff --git a/tests/test_embed.py b/tests/test_embed.py index 77b69e6..e44473a 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -230,13 +230,14 @@ class OEmbedApiTest(BaseTestCase): class OEmbedDiscoveryTest(BaseTestCase): """Test oEmbed discovery link tags on tweet pages.""" + base_url = 'http://localhost:8080' def test_tweet_page_has_oembed_link_tag(self): self.open_nitter('elonmusk/status/1141367104702038016') self.assert_element_present('link[type="application/json+oembed"]') def test_oembed_link_tag_points_to_api(self): - resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + resp = requests.get(f'{self.base_url}/elonmusk/status/1141367104702038016') self.assertIn('application/json+oembed', resp.text) self.assertIn('/api/oembed?url=', resp.text) self.assertIn('1141367104702038016', resp.text) @@ -244,11 +245,15 @@ class OEmbedDiscoveryTest(BaseTestCase): def test_oembed_discovery_roundtrip(self): """Fetch a tweet page, extract oEmbed URL, call it, verify response.""" import re - resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + from urllib.parse import urlparse + resp = requests.get(f'{self.base_url}/elonmusk/status/1141367104702038016') match = re.search( r'type="application/json\+oembed"\s+href="([^"]*)"', resp.text) self.assertIsNotNone(match, "No oEmbed discovery link found in page") oembed_url = match.group(1).replace('&', '&') + # Rewrite host to base_url in case cfg.hostname differs (e.g. CI) + parsed = urlparse(oembed_url) + oembed_url = f'{self.base_url}{parsed.path}?{parsed.query}' oembed_resp = requests.get(oembed_url) self.assertEqual(oembed_resp.status_code, 200) data = oembed_resp.json() From f74b2282be6c22184a258c5cd402042501435ca6 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 15 Aug 2026 00:35:48 +0700 Subject: [PATCH 293/309] Set CI hostname to localhost for oEmbed tests --- .github/workflows/run-tests.yml | 1 + tests/test_embed.py | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index daccac9..8e6c814 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -137,6 +137,7 @@ jobs: cp nitter.example.conf nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf + sed -i 's/hostname = "nitter.net"/hostname = "localhost:8080"/g' nitter.conf nim r tools/rendermd.nim nim r tools/gencss.nim diff --git a/tests/test_embed.py b/tests/test_embed.py index e44473a..a1ef3e7 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -245,15 +245,11 @@ class OEmbedDiscoveryTest(BaseTestCase): def test_oembed_discovery_roundtrip(self): """Fetch a tweet page, extract oEmbed URL, call it, verify response.""" import re - from urllib.parse import urlparse resp = requests.get(f'{self.base_url}/elonmusk/status/1141367104702038016') match = re.search( r'type="application/json\+oembed"\s+href="([^"]*)"', resp.text) self.assertIsNotNone(match, "No oEmbed discovery link found in page") oembed_url = match.group(1).replace('&', '&') - # Rewrite host to base_url in case cfg.hostname differs (e.g. CI) - parsed = urlparse(oembed_url) - oembed_url = f'{self.base_url}{parsed.path}?{parsed.query}' oembed_resp = requests.get(oembed_url) self.assertEqual(oembed_resp.status_code, 200) data = oembed_resp.json() From 607765c463e78b80e85ebe797534a5e56affdf94 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 17 Aug 2026 05:28:53 +0700 Subject: [PATCH 294/309] Rewrite session scripts for new login flow Fixes #1421 --- tools/create_session_browser.py | 413 ++++++++++++++++++++++++------- tools/create_session_curl.py | 6 + tools/create_sessions_browser.py | 179 ++++---------- tools/requirements.txt | 2 +- 4 files changed, 370 insertions(+), 230 deletions(-) diff --git a/tools/create_session_browser.py b/tools/create_session_browser.py index eb3936b..65ffeab 100644 --- a/tools/create_session_browser.py +++ b/tools/create_session_browser.py @@ -23,118 +23,353 @@ Output: import asyncio import json import os +import shutil import sys +import tempfile -import nodriver as uc +import zendriver as zd +from zendriver import cdp import pyotp -async def login_and_get_cookies(username, password, totp_seed=None, headless=False): - """Authenticate with X.com and extract session cookies""" - # Note: headless mode may increase detection risk from bot-detection systems - browser = await uc.start(headless=headless) - tab = await browser.get("https://x.com/i/flow/login") +# Disable password manager to prevent the "Save password?" bubble from +# stealing focus during automated login. +_SEED_PREFS = { + "credentials_enable_service": False, + "profile": {"password_manager_enabled": False}, +} +_BROWSER_ARGS = [ + "--password-store=basic", + "--no-first-run", + "--no-default-browser-check", + "--disable-notifications", +] + +def _log(*a): + print(*a, file=sys.stderr, flush=True) + + +def _make_profile(): + """Create a temp Chrome profile with password manager disabled.""" + profile = tempfile.mkdtemp(prefix="xsess_") + default = os.path.join(profile, "Default") + os.makedirs(default) + with open(os.path.join(default, "Preferences"), "w") as f: + json.dump(_SEED_PREFS, f) + return profile + + +def _extract_user_id(cookies_dict): + """Extract numeric user ID from the twid cookie.""" + twid = cookies_dict.get("twid", "").strip('"') + for prefix in ("u%3D", "u="): + if prefix in twid: + return twid.split(prefix)[1].split("&")[0].strip('"') + return None + + +async def _check_login_error(tab): + """Check if the login flow is showing an error (wrong password, etc.).""" try: - # Enter username - print(f"[*] Entering username {username}...", file=sys.stderr) + return await tab.evaluate('''(() => { + // Check role="alert" elements (X's standard error display) + const alert = document.querySelector('[role="alert"]'); + if (alert) { + const t = alert.textContent.trim(); + if (t.length > 0 && t.length < 200) return t; + } + // Check for common error strings in visible text + for (const el of document.querySelectorAll('p, span, div')) { + const t = el.textContent.trim(); + if (t.length > 5 && t.length < 150 + && (t.includes('Wrong password') + || t.includes('incorrect') + || t.includes('Could not log you in') + || t.includes("can\\'t find") + || t.includes('cannot find') + || t.includes('suspended') + || t.includes('locked') + || t.includes('unusual login'))) { + return t; + } + } + return ''; + })()''') + except Exception: + return '' - retry = 0 - while retry < 5: - username_input = await tab.find( - 'input[autocomplete="username"]', timeout=10 - ) - pos = await username_input.get_position() - await tab.mouse_move(pos.x, pos.y, steps=50, flash=True) - await asyncio.sleep(0.1) +async def _click_continue(tab): + """Click the 'Continue' / 'Log in' button in the jf onboarding flow. - await username_input.click() - await asyncio.sleep(0.5) - await username_input.send_keys(username) - await asyncio.sleep(0.2) - await username_input.send_keys("\n") - await asyncio.sleep(2) + The button is a nested
containing

Continue

(or

Log in

), + not a standard