Add Twitter Spaces support

Fixes #459
This commit is contained in:
Zed 2026-06-19 22:00:36 +02:00
commit db5a229bfe
11 changed files with 379 additions and 3 deletions

View file

@ -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

View file

@ -39,6 +39,7 @@ const
graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds"
graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
graphAudioSpace* = "rWRLsOhNJ2xjpI1tREYurQ/AudioSpaceById"
restLiveStream* = "1.1/live_video_stream/status/"
gqlFeatures* = """{

View file

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

View file

@ -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

View file

@ -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)))

36
src/routes/space.nim Normal file
View file

@ -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

149
src/sass/_space.scss Normal file
View file

@ -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;
}

View file

@ -8,6 +8,7 @@
@import "timeline";
@import "search";
@import "broadcast";
@import "space";
@import "_article";
body {

View file

@ -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"

View file

@ -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:

86
src/views/space.nim Normal file
View file

@ -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 "<div class=\"video-overlay\" onclick=\"playAudio(this)\">"
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 "</div>"
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, "")