mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 00:52:45 -05:00
Add read-only live chat to livestreams
This commit is contained in:
parent
76ff3c006b
commit
e9bd62679b
@ -441,6 +441,99 @@ p.video-data { margin: 0; font-weight: bold; font-size: 80%; }
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.live-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
max-height: 24rem;
|
||||
min-height: 18rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.live-chat-header {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.live-chat-header h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.live-chat-mode {
|
||||
max-width: 8rem;
|
||||
}
|
||||
|
||||
.live-chat-status {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.live-chat-messages {
|
||||
flex: 1;
|
||||
min-height: 12rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.live-chat-message {
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.live-chat-message + .live-chat-message {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
|
||||
.live-chat-author,
|
||||
.live-chat-badge {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.live-chat-badge {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.live-chat-message-engagement {
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 64em) {
|
||||
.live-chat-player-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(16rem, 1fr);
|
||||
}
|
||||
|
||||
.live-chat-player-column,
|
||||
.live-chat-column {
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.live-chat-player-column #player-container {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.live-chat {
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.live-chat-column {
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.live-chat-messages {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.video-iframe-wrapper {
|
||||
position: relative;
|
||||
height: 0;
|
||||
@ -909,4 +1002,4 @@ h1, h2, h3, h4, h5, p,
|
||||
padding-left: 10px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
176
assets/js/live_chat.js
Normal file
176
assets/js/live_chat.js
Normal file
@ -0,0 +1,176 @@
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
var container = document.getElementById('live-chat-messages');
|
||||
var status = document.getElementById('live-chat-status');
|
||||
var modeSelect = document.getElementById('live-chat-mode');
|
||||
var continuation = null;
|
||||
var knownMessages = Object.create(null);
|
||||
var retryDelay = 5000;
|
||||
var maxMessages = 200;
|
||||
var pollTimer = null;
|
||||
var polling = false;
|
||||
var stopped = false;
|
||||
var generation = 0;
|
||||
|
||||
if (!container || !status || !modeSelect) return;
|
||||
|
||||
function setStatus(message) {
|
||||
status.textContent = message;
|
||||
status.hidden = false;
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
status.hidden = true;
|
||||
}
|
||||
|
||||
function removeMessage(id) {
|
||||
var element = knownMessages[id];
|
||||
if (element) element.parentNode.removeChild(element);
|
||||
delete knownMessages[id];
|
||||
}
|
||||
|
||||
function appendMessage(action) {
|
||||
if (action.id && knownMessages[action.id]) return;
|
||||
|
||||
var message = document.createElement('div');
|
||||
message.className = 'live-chat-message';
|
||||
if (action.kind === 'engagement')
|
||||
message.classList.add('live-chat-message-engagement');
|
||||
|
||||
if (action.id) {
|
||||
message.setAttribute('data-live-chat-id', action.id);
|
||||
knownMessages[action.id] = message;
|
||||
}
|
||||
if (action.author) {
|
||||
var author = document.createElement('strong');
|
||||
author.className = 'live-chat-author';
|
||||
author.textContent = action.author;
|
||||
message.appendChild(author);
|
||||
}
|
||||
|
||||
if (action.badges) {
|
||||
action.badges.forEach(function (badge) {
|
||||
var badgeElement = document.createElement('span');
|
||||
badgeElement.className = 'live-chat-badge';
|
||||
badgeElement.textContent = badge;
|
||||
message.appendChild(badgeElement);
|
||||
});
|
||||
}
|
||||
|
||||
if (action.message) {
|
||||
var body = document.createElement('span');
|
||||
body.textContent = action.message;
|
||||
message.appendChild(body);
|
||||
}
|
||||
|
||||
container.appendChild(message);
|
||||
|
||||
while (container.children.length > maxMessages) {
|
||||
var first = container.firstElementChild;
|
||||
if (first.getAttribute('data-live-chat-id'))
|
||||
delete knownMessages[first.getAttribute('data-live-chat-id')];
|
||||
container.removeChild(first);
|
||||
}
|
||||
}
|
||||
|
||||
function applyActions(actions) {
|
||||
var wasNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
|
||||
|
||||
actions.forEach(function (action) {
|
||||
if (action.action === 'add') {
|
||||
appendMessage(action);
|
||||
} else if (action.action === 'remove' && action.id) {
|
||||
removeMessage(action.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (wasNearBottom) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function schedulePoll(timeout) {
|
||||
if (stopped || document.hidden) return;
|
||||
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = setTimeout(poll, Math.max(1000, Math.min(timeout || 10000, 30000)));
|
||||
}
|
||||
|
||||
function reconnect(requestGeneration) {
|
||||
if (requestGeneration !== generation) return;
|
||||
|
||||
polling = false;
|
||||
setStatus(video_data.live_chat.reconnecting_text);
|
||||
schedulePoll(retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 2, 30000);
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (polling || stopped || document.hidden) return;
|
||||
polling = true;
|
||||
var requestGeneration = generation;
|
||||
|
||||
var url = '/api/v1/live_chat/' + encodeURIComponent(video_data.id);
|
||||
var query = ['mode=' + encodeURIComponent(modeSelect.value)];
|
||||
|
||||
if (continuation)
|
||||
query.push('continuation=' + encodeURIComponent(continuation));
|
||||
if (video_data.params.region)
|
||||
query.push('region=' + encodeURIComponent(video_data.params.region));
|
||||
url += '?' + query.join('&');
|
||||
|
||||
helpers.xhr('GET', url, {timeout: 15000}, {
|
||||
on200: function (response) {
|
||||
if (requestGeneration !== generation) return;
|
||||
|
||||
polling = false;
|
||||
clearStatus();
|
||||
retryDelay = 5000;
|
||||
applyActions(response.actions || []);
|
||||
continuation = response.continuation;
|
||||
|
||||
if (continuation) {
|
||||
schedulePoll(response.timeoutMs);
|
||||
} else {
|
||||
stopped = true;
|
||||
setStatus(video_data.live_chat.ended_text);
|
||||
}
|
||||
},
|
||||
onNon200: function (xhr) {
|
||||
if (requestGeneration !== generation) return;
|
||||
|
||||
if (xhr.status === 404 || xhr.status === 410) {
|
||||
polling = false;
|
||||
stopped = true;
|
||||
setStatus(video_data.live_chat.unavailable_text);
|
||||
} else {
|
||||
reconnect(requestGeneration);
|
||||
}
|
||||
},
|
||||
onError: function () { reconnect(requestGeneration); },
|
||||
onTimeout: function () { reconnect(requestGeneration); }
|
||||
});
|
||||
}
|
||||
|
||||
modeSelect.addEventListener('change', function () {
|
||||
generation += 1;
|
||||
clearTimeout(pollTimer);
|
||||
continuation = null;
|
||||
knownMessages = Object.create(null);
|
||||
retryDelay = 5000;
|
||||
polling = false;
|
||||
stopped = false;
|
||||
container.textContent = '';
|
||||
setStatus(video_data.live_chat.loading_text);
|
||||
poll();
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
clearTimeout(pollTimer);
|
||||
} else if (!stopped) {
|
||||
poll();
|
||||
}
|
||||
});
|
||||
|
||||
poll();
|
||||
}());
|
||||
@ -462,6 +462,7 @@ feed_threads: 1
|
||||
## - /api/v1/videos
|
||||
## - /api/v1/clips
|
||||
## - /api/v1/transcripts
|
||||
## - /api/v1/live_chat
|
||||
##
|
||||
## Accepted values: true, false
|
||||
## Default: false
|
||||
|
||||
@ -22,6 +22,14 @@
|
||||
"generic_button_cancel": "Cancel",
|
||||
"generic_button_rss": "RSS",
|
||||
"LIVE": "LIVE",
|
||||
"Live chat": "Live chat",
|
||||
"Top chat": "Top chat",
|
||||
"Live chat is read-only.": "Live chat is read-only.",
|
||||
"Loading live chat...": "Loading live chat...",
|
||||
"Live chat is unavailable.": "Live chat is unavailable.",
|
||||
"Live chat has ended.": "Live chat has ended.",
|
||||
"Reconnecting to live chat...": "Reconnecting to live chat...",
|
||||
"JavaScript is required to view live chat.": "JavaScript is required to view live chat.",
|
||||
"Shared `x` ago": "Shared `x` ago",
|
||||
"Unsubscribe": "Unsubscribe",
|
||||
"Subscribe": "Subscribe",
|
||||
|
||||
17
spec/fixtures/live_chat/initial.json
vendored
Normal file
17
spec/fixtures/live_chat/initial.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"contents": {
|
||||
"twoColumnWatchNextResults": {
|
||||
"conversationBar": {
|
||||
"liveChatRenderer": {
|
||||
"continuations": [
|
||||
{
|
||||
"reloadContinuationData": {
|
||||
"continuation": "top-chat-token"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
spec/fixtures/live_chat/response.json
vendored
Normal file
115
spec/fixtures/live_chat/response.json
vendored
Normal file
@ -0,0 +1,115 @@
|
||||
{
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"header": {
|
||||
"liveChatHeaderRenderer": {
|
||||
"viewSelector": {
|
||||
"sortFilterSubMenuRenderer": {
|
||||
"subMenuItems": [
|
||||
{
|
||||
"title": "Top chat",
|
||||
"selected": true,
|
||||
"continuation": {
|
||||
"reloadContinuationData": {
|
||||
"continuation": "top-chat-mode-token"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Live chat",
|
||||
"selected": false,
|
||||
"continuation": {
|
||||
"reloadContinuationData": {
|
||||
"continuation": "live-chat-mode-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"clickTrackingParams": "tracking",
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatTextMessageRenderer": {
|
||||
"id": "message-1",
|
||||
"authorExternalChannelId": "channel-1",
|
||||
"authorName": {
|
||||
"simpleText": "Viewer"
|
||||
},
|
||||
"authorBadges": [
|
||||
{
|
||||
"liveChatAuthorBadgeRenderer": {
|
||||
"tooltip": "Member",
|
||||
"accessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "Member"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "Hello "
|
||||
},
|
||||
{
|
||||
"emoji": {
|
||||
"emojiId": "wave",
|
||||
"image": {
|
||||
"accessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "wave"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickTrackingParams": "tracking",
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatViewerEngagementMessageRenderer": {
|
||||
"id": "notice-1",
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "Subscribers-only mode."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickTrackingParams": "tracking",
|
||||
"removeChatItemAction": {
|
||||
"targetItemId": "message-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickTrackingParams": "tracking"
|
||||
}
|
||||
],
|
||||
"continuations": [
|
||||
{
|
||||
"invalidationContinuationData": {
|
||||
"timeoutMs": 4321,
|
||||
"continuation": "next-token"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
80
spec/invidious/live_chat_spec.cr
Normal file
80
spec/invidious/live_chat_spec.cr
Normal file
@ -0,0 +1,80 @@
|
||||
require "../spec_helper"
|
||||
|
||||
private def live_chat_fixture(name : String) : Hash(String, JSON::Any)
|
||||
path = File.join(__DIR__, "..", "fixtures", "live_chat", "#{name}.json")
|
||||
return JSON.parse(File.read(path)).as_h
|
||||
end
|
||||
|
||||
Spectator.describe Invidious::LiveChat do
|
||||
# These fixtures are reduced, anonymized payloads captured from an active stream.
|
||||
it "extracts initial and mode-switch continuations from their response stages" do
|
||||
initial_response = live_chat_fixture("initial")
|
||||
chat_response = live_chat_fixture("response")
|
||||
|
||||
expect(Invidious::LiveChat.extract_initial_continuation(initial_response)).to eq("top-chat-token")
|
||||
expect(Invidious::LiveChat.extract_live_continuation(chat_response)).to eq("live-chat-mode-token")
|
||||
end
|
||||
|
||||
it "normalizes observed messages, notices, removals, and polling data" do
|
||||
result = Invidious::LiveChat.parse_response(live_chat_fixture("response"))
|
||||
|
||||
expect(result.continuation).to eq("next-token")
|
||||
expect(result.timeout_ms).to eq(4321)
|
||||
expect(result.actions.size).to eq(3)
|
||||
|
||||
message = result.actions[0]
|
||||
expect(message.action).to eq("add")
|
||||
expect(message.id).to eq("message-1")
|
||||
expect(message.kind).to eq("text")
|
||||
expect(message.author).to eq("Viewer")
|
||||
expect(message.message).to eq("Hello wave")
|
||||
expect(message.badges).to eq(["Member"])
|
||||
|
||||
notice = result.actions[1]
|
||||
expect(notice.kind).to eq("engagement")
|
||||
expect(notice.message).to eq("Subscribers-only mode.")
|
||||
|
||||
removal = result.actions[2]
|
||||
expect(removal.action).to eq("remove")
|
||||
expect(removal.id).to eq("message-0")
|
||||
end
|
||||
|
||||
it "ignores malformed and unsupported chat items" do
|
||||
response = JSON.parse(<<-JSON).as_h
|
||||
{
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"actions": [
|
||||
{"addChatItemAction": {"item": {"liveChatTextMessageRenderer": "invalid"}}},
|
||||
{"addChatItemAction": {"item": {"unsupportedRenderer": {"id": "ignored"}}}}
|
||||
],
|
||||
"continuations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(Invidious::LiveChat.parse_response(response).actions).to be_empty
|
||||
end
|
||||
|
||||
it "clamps polling intervals" do
|
||||
response = live_chat_fixture("response")
|
||||
polling_data = response
|
||||
.dig("continuationContents", "liveChatContinuation", "continuations")
|
||||
.as_a[0]["invalidationContinuationData"]
|
||||
|
||||
polling_data.as_h["timeoutMs"] = JSON::Any.new(100_i64)
|
||||
expect(Invidious::LiveChat.parse_response(response).timeout_ms).to eq(1000)
|
||||
|
||||
polling_data.as_h["timeoutMs"] = JSON::Any.new(60_000_i64)
|
||||
expect(Invidious::LiveChat.parse_response(response).timeout_ms).to eq(30_000)
|
||||
end
|
||||
|
||||
it "returns an ended response when live-chat contents are absent" do
|
||||
result = Invidious::LiveChat.parse_response({} of String => JSON::Any)
|
||||
|
||||
expect(result.continuation).to be_nil
|
||||
expect(result.timeout_ms).to eq(10_000)
|
||||
expect(result.actions).to be_empty
|
||||
end
|
||||
end
|
||||
@ -7,6 +7,8 @@ require "../src/invidious/helpers/*"
|
||||
require "../src/invidious/channels/*"
|
||||
require "../src/invidious/videos/caption"
|
||||
require "../src/invidious/videos"
|
||||
require "../src/invidious/yt_backend/youtube_api"
|
||||
require "../src/invidious/live_chat"
|
||||
require "../src/invidious/playlists"
|
||||
require "../src/invidious/search/ctoken"
|
||||
require "../src/invidious/trending"
|
||||
|
||||
@ -135,8 +135,8 @@ end
|
||||
|
||||
class DisableAbusableAPIHandler < Kemal::Handler
|
||||
{% for method in %w(GET HEAD) %}
|
||||
# This endpoints make a video request to Invidious companion.
|
||||
{% for endpoint in %w(videos clips transcripts) %}
|
||||
# These endpoints make upstream video-related requests.
|
||||
{% for endpoint in %w(videos clips transcripts live_chat) %}
|
||||
only ["/api/v1/{{ endpoint.id }}/:id"], {{ method }}
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
297
src/invidious/live_chat.cr
Normal file
297
src/invidious/live_chat.cr
Normal file
@ -0,0 +1,297 @@
|
||||
module Invidious::LiveChat
|
||||
extend self
|
||||
|
||||
alias CacheKey = Tuple(String, String, String, String)
|
||||
|
||||
CACHE_LIMIT = 256
|
||||
FETCH_LOCKS = Array(Mutex).new(16) { Mutex.new }
|
||||
|
||||
record CacheEntry, body : String, expires_at : Time::Instant
|
||||
|
||||
@@cache = {} of CacheKey => CacheEntry
|
||||
@@cache_mutex = Mutex.new
|
||||
|
||||
struct Action
|
||||
include JSON::Serializable
|
||||
|
||||
getter action : String
|
||||
|
||||
@[JSON::Field(emit_null: false)]
|
||||
getter id : String?
|
||||
|
||||
@[JSON::Field(emit_null: false)]
|
||||
getter kind : String?
|
||||
|
||||
@[JSON::Field(emit_null: false)]
|
||||
getter author : String?
|
||||
|
||||
@[JSON::Field(emit_null: false)]
|
||||
getter message : String?
|
||||
|
||||
@[JSON::Field(emit_null: false)]
|
||||
getter badges : Array(String)?
|
||||
|
||||
def initialize(
|
||||
@action : String,
|
||||
@id : String? = nil,
|
||||
@kind : String? = nil,
|
||||
@author : String? = nil,
|
||||
@message : String? = nil,
|
||||
@badges : Array(String)? = nil,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
struct Response
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(emit_null: false)]
|
||||
getter continuation : String?
|
||||
|
||||
@[JSON::Field(key: "timeoutMs")]
|
||||
getter timeout_ms : Int32
|
||||
|
||||
getter actions : Array(Action)
|
||||
|
||||
def initialize(
|
||||
@continuation : String?,
|
||||
@timeout_ms : Int32,
|
||||
@actions : Array(Action),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def fetch(video_id : String, continuation : String?, region : String?, mode : String) : String
|
||||
cache_key = {video_id, continuation || "", region || "", mode}
|
||||
|
||||
fetch_lock(cache_key).synchronize do
|
||||
if cached = cached_response(cache_key)
|
||||
return cached
|
||||
end
|
||||
|
||||
client_config = YoutubeAPI::ClientConfig.new(region: region)
|
||||
response = if continuation
|
||||
YoutubeAPI.live_chat(continuation, client_config: client_config)
|
||||
else
|
||||
fetch_initial_response(video_id, mode, client_config)
|
||||
end
|
||||
|
||||
response = parse_response(response)
|
||||
body = response.to_json
|
||||
cache_response(cache_key, body, response.timeout_ms)
|
||||
return body
|
||||
end
|
||||
end
|
||||
|
||||
private def fetch_initial_response(
|
||||
video_id : String,
|
||||
mode : String,
|
||||
client_config : YoutubeAPI::ClientConfig,
|
||||
) : Hash(String, JSON::Any)
|
||||
next_response = YoutubeAPI.next({"videoId" => video_id}, client_config: client_config)
|
||||
initial_continuation = extract_initial_continuation(next_response) ||
|
||||
raise NotFoundException.new("Live chat is unavailable.")
|
||||
initial_response = YoutubeAPI.live_chat(initial_continuation, client_config: client_config)
|
||||
return initial_response unless mode == "live"
|
||||
|
||||
live_continuation = extract_live_continuation(initial_response) ||
|
||||
raise NotFoundException.new("Live chat is unavailable.")
|
||||
return YoutubeAPI.live_chat(live_continuation, client_config: client_config)
|
||||
end
|
||||
|
||||
def extract_initial_continuation(response : Hash(String, JSON::Any)) : String?
|
||||
live_chat = response
|
||||
.dig?("contents", "twoColumnWatchNextResults", "conversationBar", "liveChatRenderer")
|
||||
return nil unless live_chat
|
||||
|
||||
continuations = live_chat["continuations"]?.try &.as_a?
|
||||
return nil unless continuations
|
||||
|
||||
continuations.each do |entry|
|
||||
continuation = continuation_from(entry["reloadContinuationData"]?)
|
||||
return continuation if continuation
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
def extract_live_continuation(response : Hash(String, JSON::Any)) : String?
|
||||
items = response
|
||||
.dig?(
|
||||
"continuationContents",
|
||||
"liveChatContinuation",
|
||||
"header",
|
||||
"liveChatHeaderRenderer",
|
||||
"viewSelector",
|
||||
"sortFilterSubMenuRenderer",
|
||||
"subMenuItems",
|
||||
)
|
||||
.try &.as_a?
|
||||
|
||||
live_item = items.try &.find do |item|
|
||||
item["title"]?.try(&.as_s?) == "Live chat"
|
||||
end
|
||||
|
||||
return continuation_from(live_item.try &.dig?("continuation", "reloadContinuationData"))
|
||||
end
|
||||
|
||||
def parse_response(response : Hash(String, JSON::Any)) : Response
|
||||
live_chat = response.dig?("continuationContents", "liveChatContinuation")
|
||||
return Response.new(nil, 10_000, [] of Action) unless live_chat
|
||||
|
||||
actions = [] of Action
|
||||
|
||||
live_chat["actions"]?.try(&.as_a?).try &.each do |action|
|
||||
if added = parse_add_action(action)
|
||||
actions << added
|
||||
elsif target_id = action.dig?("removeChatItemAction", "targetItemId").try &.as_s?
|
||||
actions << Action.new("remove", id: target_id)
|
||||
end
|
||||
end
|
||||
|
||||
continuation, timeout_ms = extract_polling_data(live_chat["continuations"]?.try(&.as_a?))
|
||||
return Response.new(continuation, timeout_ms, actions)
|
||||
end
|
||||
|
||||
private def fetch_lock(cache_key : CacheKey) : Mutex
|
||||
index = (cache_key.hash % FETCH_LOCKS.size).to_i
|
||||
return FETCH_LOCKS[index]
|
||||
end
|
||||
|
||||
private def cached_response(cache_key : CacheKey) : String?
|
||||
now = Time.instant
|
||||
|
||||
@@cache_mutex.synchronize do
|
||||
entry = @@cache[cache_key]?
|
||||
return nil unless entry
|
||||
|
||||
if entry.expires_at <= now
|
||||
@@cache.delete(cache_key)
|
||||
return nil
|
||||
end
|
||||
|
||||
return entry.body
|
||||
end
|
||||
end
|
||||
|
||||
private def cache_response(cache_key : CacheKey, body : String, timeout_ms : Int32)
|
||||
now = Time.instant
|
||||
|
||||
@@cache_mutex.synchronize do
|
||||
expired_keys = @@cache.compact_map do |key, entry|
|
||||
key if entry.expires_at <= now
|
||||
end
|
||||
expired_keys.each { |key| @@cache.delete(key) }
|
||||
|
||||
if @@cache.size >= CACHE_LIMIT && !@@cache.has_key?(cache_key)
|
||||
oldest_key = @@cache.keys.min_by { |key| @@cache[key].expires_at }
|
||||
@@cache.delete(oldest_key)
|
||||
end
|
||||
|
||||
@@cache[cache_key] = CacheEntry.new(body, now + timeout_ms.milliseconds)
|
||||
end
|
||||
end
|
||||
|
||||
private def extract_polling_data(continuations : Array(JSON::Any)?) : {String?, Int32}
|
||||
continuations.try &.each do |entry|
|
||||
data = entry["invalidationContinuationData"]?
|
||||
next unless data
|
||||
|
||||
continuation = continuation_from(data)
|
||||
next unless continuation
|
||||
|
||||
timeout_ms = data["timeoutMs"]?.try(&.as_i?).try(&.to_i) || 10_000
|
||||
return {continuation, timeout_ms.clamp(1_000, 30_000)}
|
||||
end
|
||||
|
||||
return {nil, 10_000}
|
||||
end
|
||||
|
||||
private def continuation_from(data : JSON::Any?) : String?
|
||||
return data.try &.["continuation"]?.try &.as_s?
|
||||
end
|
||||
|
||||
private def parse_add_action(action : JSON::Any) : Action?
|
||||
item = action.dig?("addChatItemAction", "item")
|
||||
return nil unless item
|
||||
|
||||
if renderer = item["liveChatTextMessageRenderer"]?
|
||||
return parse_text_message(renderer)
|
||||
end
|
||||
|
||||
if renderer = item["liveChatViewerEngagementMessageRenderer"]?
|
||||
return parse_engagement_message(renderer)
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
private def parse_text_message(renderer : JSON::Any) : Action?
|
||||
return nil unless renderer.as_h?
|
||||
|
||||
id = renderer["id"]?.try &.as_s?
|
||||
author = extract_text(renderer["authorName"]?)
|
||||
message = extract_text(renderer["message"]?)
|
||||
return nil if author.empty? && message.empty?
|
||||
|
||||
return Action.new(
|
||||
"add",
|
||||
id: id,
|
||||
kind: "text",
|
||||
author: author.empty? ? nil : author,
|
||||
message: message.empty? ? nil : message,
|
||||
badges: extract_badges(renderer),
|
||||
)
|
||||
end
|
||||
|
||||
private def parse_engagement_message(renderer : JSON::Any) : Action?
|
||||
return nil unless renderer.as_h?
|
||||
|
||||
message = extract_text(renderer["message"]?)
|
||||
return nil if message.empty?
|
||||
|
||||
return Action.new(
|
||||
"add",
|
||||
id: renderer["id"]?.try &.as_s?,
|
||||
kind: "engagement",
|
||||
message: message,
|
||||
)
|
||||
end
|
||||
|
||||
private def extract_text(value : JSON::Any?) : String
|
||||
return "" unless value
|
||||
|
||||
object = value.as_h?
|
||||
return value.as_s? || "" unless object
|
||||
|
||||
if simple_text = object["simpleText"]?.try &.as_s?
|
||||
return simple_text
|
||||
end
|
||||
|
||||
runs = object["runs"]?.try &.as_a?
|
||||
return "" unless runs
|
||||
|
||||
return String.build do |str|
|
||||
runs.each do |run|
|
||||
if text = run["text"]?.try &.as_s?
|
||||
str << text
|
||||
elsif label = run
|
||||
.dig?("emoji", "image", "accessibility", "accessibilityData", "label")
|
||||
.try &.as_s?
|
||||
str << label
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def extract_badges(renderer : JSON::Any) : Array(String)?
|
||||
badges = renderer["authorBadges"]?.try &.as_a?
|
||||
return nil unless badges
|
||||
|
||||
labels = badges.compact_map do |badge|
|
||||
badge.dig?("liveChatAuthorBadgeRenderer", "tooltip").try &.as_s?
|
||||
end
|
||||
|
||||
return labels.empty? ? nil : labels
|
||||
end
|
||||
end
|
||||
@ -390,6 +390,31 @@ module Invidious::Routes::API::V1::Videos
|
||||
end
|
||||
end
|
||||
|
||||
def self.live_chat(env)
|
||||
env.response.content_type = "application/json"
|
||||
|
||||
id = env.params.url["id"]
|
||||
continuation = env.params.query["continuation"]?
|
||||
region = env.params.query["region"]?
|
||||
mode = env.params.query["mode"]? || "top"
|
||||
|
||||
if id.size != 11 || !id.matches?(/^[\w-]+$/)
|
||||
return error_json(400, "Invalid video ID")
|
||||
end
|
||||
|
||||
unless {"top", "live"}.includes?(mode)
|
||||
return error_json(400, "Invalid live chat mode")
|
||||
end
|
||||
|
||||
begin
|
||||
return Invidious::LiveChat.fetch(id, continuation, region, mode)
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
end
|
||||
|
||||
def self.clips(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
|
||||
@ -249,6 +249,7 @@ module Invidious::Routing
|
||||
get "/api/v1/captions/:id", {{namespace}}::Videos, :captions
|
||||
get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
|
||||
get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
|
||||
get "/api/v1/live_chat/:id", {{namespace}}::Videos, :live_chat
|
||||
get "/api/v1/clips/:id", {{namespace}}::Videos, :clips
|
||||
get "/api/v1/transcripts/:id", {{namespace}}::Videos, :transcripts
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<% ucid = video.ucid %>
|
||||
<% title = HTML.escape(video.title) %>
|
||||
<% author = HTML.escape(video.author) %>
|
||||
<% live_chat_enabled = video.live_now && !CONFIG.disable_abusable_api %>
|
||||
|
||||
|
||||
<% content_for "header" do %>
|
||||
@ -66,14 +67,49 @@ we're going to need to do it here in order to allow for translations.
|
||||
"projection_type" => video.projection_type,
|
||||
"local_disabled" => CONFIG.disabled?("local"),
|
||||
"support_reddit" => true,
|
||||
"live_chat" => live_chat_enabled ? {
|
||||
"loading_text" => HTML.escape(I18n.translate(locale, "Loading live chat...")),
|
||||
"unavailable_text" => HTML.escape(I18n.translate(locale, "Live chat is unavailable.")),
|
||||
"ended_text" => HTML.escape(I18n.translate(locale, "Live chat has ended.")),
|
||||
"reconnecting_text" => HTML.escape(I18n.translate(locale, "Reconnecting to live chat...")),
|
||||
} : nil,
|
||||
"live_now" => video.live_now
|
||||
}.to_pretty_json
|
||||
%>
|
||||
</script>
|
||||
|
||||
<div id="player-container" class="h-box">
|
||||
<%= rendered "components/player" %>
|
||||
</div>
|
||||
<% if live_chat_enabled %>
|
||||
<div class="pure-g live-chat-player-row">
|
||||
<div class="pure-u-1 pure-u-lg-3-4 live-chat-player-column">
|
||||
<div id="player-container" class="h-box">
|
||||
<%= rendered "components/player" %>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="pure-u-1 pure-u-lg-1-4 live-chat-column">
|
||||
<section id="live-chat" class="h-box live-chat" aria-labelledby="live-chat-title">
|
||||
<header class="live-chat-header">
|
||||
<div>
|
||||
<h2 id="live-chat-title"><%= I18n.translate(locale, "Live chat") %></h2>
|
||||
<small><%= I18n.translate(locale, "Live chat is read-only.") %></small>
|
||||
</div>
|
||||
<select id="live-chat-mode" class="live-chat-mode" aria-label="<%= I18n.translate(locale, "Live chat") %>">
|
||||
<option value="top"><%= I18n.translate(locale, "Top chat") %></option>
|
||||
<option value="live"><%= I18n.translate(locale, "Live chat") %></option>
|
||||
</select>
|
||||
</header>
|
||||
<div id="live-chat-status" class="live-chat-status" role="status" aria-live="polite">
|
||||
<%= I18n.translate(locale, "Loading live chat...") %>
|
||||
</div>
|
||||
<div id="live-chat-messages" class="live-chat-messages" role="log" aria-live="off"></div>
|
||||
<noscript><p><%= I18n.translate(locale, "JavaScript is required to view live chat.") %></p></noscript>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
<% else %>
|
||||
<div id="player-container" class="h-box">
|
||||
<%= rendered "components/player" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="h-box">
|
||||
<h1>
|
||||
@ -371,3 +407,6 @@ we're going to need to do it here in order to allow for translations.
|
||||
</div>
|
||||
<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script>
|
||||
<script src="/js/watch.js?v=<%= ASSET_COMMIT %>"></script>
|
||||
<% if live_chat_enabled %>
|
||||
<script src="/js/live_chat.js?v=<%= ASSET_COMMIT %>"></script>
|
||||
<% end %>
|
||||
|
||||
@ -78,7 +78,7 @@ module YoutubeAPI
|
||||
name: "WEB",
|
||||
name_proto: "1",
|
||||
|
||||
version: "2.20260722.01.00",
|
||||
version: "2.20260722.01.00",
|
||||
|
||||
screen: "EMBED",
|
||||
os_name: "Windows",
|
||||
@ -449,6 +449,22 @@ module YoutubeAPI
|
||||
return self.next(data.to_h, client_config: client_config)
|
||||
end
|
||||
|
||||
####################################################################
|
||||
# live_chat(continuation, client_config?)
|
||||
#
|
||||
# Requests the youtubei/v1/live_chat/get_live_chat endpoint using a
|
||||
# continuation obtained from the watch-next response or a previous
|
||||
# live-chat response.
|
||||
#
|
||||
def live_chat(continuation : String, *, client_config : ClientConfig | Nil = nil)
|
||||
data = {
|
||||
"context" => self.make_context(client_config),
|
||||
"continuation" => continuation,
|
||||
}
|
||||
|
||||
return self._post_json("/youtubei/v1/live_chat/get_live_chat", data, client_config)
|
||||
end
|
||||
|
||||
####################################################################
|
||||
# player(video_id)
|
||||
#
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user