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, "")