Procházet zdrojové kódy

Add Twitter Spaces support

Fixes #459
Zed před 2 měsíci
rodič
revize
db5a229bfe

+ 13 - 0
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

+ 1 - 0
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* = """{

+ 3 - 1
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, ""

+ 52 - 1
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
 

+ 15 - 0
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)))
 

+ 36 - 0
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

+ 149 - 0
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;
+}

+ 1 - 0
src/sass/index.scss

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

+ 22 - 0
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"

+ 1 - 1
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:

+ 86 - 0
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 "<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, "")