mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add 7TV emote support
This commit is contained in:
@@ -83,6 +83,8 @@ public class SecurityConfig {
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/twitch/emotes/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/7tv/emotes/**")
|
||||
.permitAll()
|
||||
.requestMatchers("/ws/**")
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.SevenTvEmoteService;
|
||||
import java.util.List;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/7tv/emotes")
|
||||
public class SevenTvEmoteController {
|
||||
|
||||
private final SevenTvEmoteService sevenTvEmoteService;
|
||||
|
||||
public SevenTvEmoteController(SevenTvEmoteService sevenTvEmoteService) {
|
||||
this.sevenTvEmoteService = sevenTvEmoteService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public EmoteCatalogResponse fetchEmoteCatalog(@RequestParam(value = "channel", required = false) String channel) {
|
||||
List<SevenTvEmoteService.EmoteDescriptor> channelEmotes = sevenTvEmoteService.getChannelEmotes(channel);
|
||||
return new EmoteCatalogResponse(channelEmotes);
|
||||
}
|
||||
|
||||
@GetMapping("/{emoteId}")
|
||||
public ResponseEntity<byte[]> fetchEmoteAsset(@PathVariable("emoteId") String emoteId) {
|
||||
return sevenTvEmoteService
|
||||
.loadEmoteAsset(emoteId)
|
||||
.map((asset) -> ResponseEntity.ok().contentType(MediaType.parseMediaType(asset.mediaType())).body(asset.bytes()))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
public record EmoteCatalogResponse(List<SevenTvEmoteService.EmoteDescriptor> channel) {}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ public class Channel {
|
||||
@Column(name = "allow_channel_emotes_for_assets", nullable = false)
|
||||
private boolean allowChannelEmotesForAssets = true;
|
||||
|
||||
@Column(name = "allow_7tv_emotes_for_assets", nullable = false)
|
||||
private boolean allowSevenTvEmotesForAssets = true;
|
||||
|
||||
@Column(name = "allow_script_chat_access", nullable = false)
|
||||
private boolean allowScriptChatAccess = true;
|
||||
|
||||
@@ -93,6 +96,14 @@ public class Channel {
|
||||
this.allowChannelEmotesForAssets = allowChannelEmotesForAssets;
|
||||
}
|
||||
|
||||
public boolean isAllowSevenTvEmotesForAssets() {
|
||||
return allowSevenTvEmotesForAssets;
|
||||
}
|
||||
|
||||
public void setAllowSevenTvEmotesForAssets(boolean allowSevenTvEmotesForAssets) {
|
||||
this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets;
|
||||
}
|
||||
|
||||
public boolean isAllowScriptChatAccess() {
|
||||
return allowScriptChatAccess;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@ package dev.kruhlmann.imgfloat.model;
|
||||
public class ChannelScriptSettingsRequest {
|
||||
|
||||
private boolean allowChannelEmotesForAssets = true;
|
||||
private boolean allowSevenTvEmotesForAssets = true;
|
||||
private boolean allowScriptChatAccess = true;
|
||||
|
||||
public ChannelScriptSettingsRequest() {}
|
||||
|
||||
public ChannelScriptSettingsRequest(boolean allowChannelEmotesForAssets, boolean allowScriptChatAccess) {
|
||||
public ChannelScriptSettingsRequest(
|
||||
boolean allowChannelEmotesForAssets,
|
||||
boolean allowSevenTvEmotesForAssets,
|
||||
boolean allowScriptChatAccess
|
||||
) {
|
||||
this.allowChannelEmotesForAssets = allowChannelEmotesForAssets;
|
||||
this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets;
|
||||
this.allowScriptChatAccess = allowScriptChatAccess;
|
||||
}
|
||||
|
||||
@@ -20,6 +26,14 @@ public class ChannelScriptSettingsRequest {
|
||||
this.allowChannelEmotesForAssets = allowChannelEmotesForAssets;
|
||||
}
|
||||
|
||||
public boolean isAllowSevenTvEmotesForAssets() {
|
||||
return allowSevenTvEmotesForAssets;
|
||||
}
|
||||
|
||||
public void setAllowSevenTvEmotesForAssets(boolean allowSevenTvEmotesForAssets) {
|
||||
this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets;
|
||||
}
|
||||
|
||||
public boolean isAllowScriptChatAccess() {
|
||||
return allowScriptChatAccess;
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ public class ChannelDirectoryService {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
return new ChannelScriptSettingsRequest(
|
||||
channel.isAllowChannelEmotesForAssets(),
|
||||
channel.isAllowSevenTvEmotesForAssets(),
|
||||
channel.isAllowScriptChatAccess()
|
||||
);
|
||||
}
|
||||
@@ -208,10 +209,12 @@ public class ChannelDirectoryService {
|
||||
) {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
channel.setAllowChannelEmotesForAssets(request.isAllowChannelEmotesForAssets());
|
||||
channel.setAllowSevenTvEmotesForAssets(request.isAllowSevenTvEmotesForAssets());
|
||||
channel.setAllowScriptChatAccess(request.isAllowScriptChatAccess());
|
||||
channelRepository.save(channel);
|
||||
return new ChannelScriptSettingsRequest(
|
||||
channel.isAllowChannelEmotesForAssets(),
|
||||
channel.isAllowSevenTvEmotesForAssets(),
|
||||
channel.isAllowScriptChatAccess()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
@Service
|
||||
public class SevenTvEmoteService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SevenTvEmoteService.class);
|
||||
private static final String USERS_URL = "https://api.twitch.tv/helix/users";
|
||||
private static final String USER_EMOTE_URL = "https://7tv.io/v3/users/twitch/";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final TwitchAppAccessTokenService tokenService;
|
||||
private final Path cacheRoot;
|
||||
private final Map<String, CachedEmote> emoteCache = new ConcurrentHashMap<>();
|
||||
private final Map<String, List<CachedEmote>> channelEmoteCache = new ConcurrentHashMap<>();
|
||||
|
||||
public SevenTvEmoteService(
|
||||
RestTemplateBuilder builder,
|
||||
TwitchAppAccessTokenService tokenService,
|
||||
@Value("${IMGFLOAT_7TV_EMOTE_CACHE_PATH:#{null}}") String cachePath
|
||||
) {
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(20))
|
||||
.setReadTimeout(Duration.ofSeconds(20))
|
||||
.build();
|
||||
this.tokenService = tokenService;
|
||||
String root = cachePath != null
|
||||
? cachePath
|
||||
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-7tv-emotes").toString();
|
||||
this.cacheRoot = Paths.get(root).normalize().toAbsolutePath();
|
||||
try {
|
||||
Files.createDirectories(this.cacheRoot);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Failed to create 7TV emote cache directory", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public List<EmoteDescriptor> getChannelEmotes(String channelLogin) {
|
||||
if (channelLogin == null || channelLogin.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
String normalized = channelLogin.toLowerCase(Locale.ROOT);
|
||||
List<CachedEmote> emotes = channelEmoteCache.computeIfAbsent(normalized, this::fetchChannelEmotes);
|
||||
return emotes.stream().map(CachedEmote::descriptor).toList();
|
||||
}
|
||||
|
||||
public Optional<EmoteAsset> loadEmoteAsset(String emoteId) {
|
||||
if (emoteId == null || emoteId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
CachedEmote cached = emoteCache.get(emoteId);
|
||||
if (cached == null) {
|
||||
cached = restoreFromDisk(emoteId).orElse(null);
|
||||
}
|
||||
if (cached == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
byte[] bytes = Files.readAllBytes(cached.path());
|
||||
return Optional.of(new EmoteAsset(bytes, cached.mediaType()));
|
||||
} catch (IOException ex) {
|
||||
LOG.warn("Unable to read cached 7TV emote {}", emoteId, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private List<CachedEmote> fetchChannelEmotes(String channelLogin) {
|
||||
String broadcasterId = fetchBroadcasterId(channelLogin).orElse(null);
|
||||
if (broadcasterId == null) {
|
||||
return List.of();
|
||||
}
|
||||
String url = USER_EMOTE_URL + broadcasterId;
|
||||
SevenTvUserResponse response = fetchEmotes(url).orElse(null);
|
||||
SevenTvEmoteSet set = response != null ? response.emoteSet() : null;
|
||||
if (set == null || set.emotes() == null || set.emotes().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CachedEmote> cached = set
|
||||
.emotes()
|
||||
.stream()
|
||||
.map(this::cacheEmote)
|
||||
.filter(Optional::isPresent)
|
||||
.map(Optional::get)
|
||||
.toList();
|
||||
LOG.info("Loaded {} 7TV emotes for {}", cached.size(), channelLogin);
|
||||
return cached;
|
||||
}
|
||||
|
||||
private Optional<String> fetchBroadcasterId(String channelLogin) {
|
||||
Optional<String> token = tokenService.getAccessToken();
|
||||
Optional<String> clientId = tokenService.getClientId();
|
||||
if (token.isEmpty() || clientId.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(USERS_URL).queryParam("login", channelLogin);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(token.get());
|
||||
headers.add("Client-ID", clientId.get());
|
||||
try {
|
||||
ResponseEntity<TwitchUsersResponse> response = restTemplate.exchange(
|
||||
builder.build(true).toUri(),
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
TwitchUsersResponse.class
|
||||
);
|
||||
TwitchUsersResponse body = response.getBody();
|
||||
if (body == null || body.data() == null || body.data().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return body.data().stream().findFirst().map(TwitchUserData::id);
|
||||
} catch (RestClientException ex) {
|
||||
LOG.warn("Unable to fetch Twitch broadcaster id for {}", channelLogin, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<SevenTvUserResponse> fetchEmotes(String url) {
|
||||
try {
|
||||
ResponseEntity<SevenTvUserResponse> response = restTemplate.exchange(
|
||||
URI.create(url),
|
||||
HttpMethod.GET,
|
||||
HttpEntity.EMPTY,
|
||||
SevenTvUserResponse.class
|
||||
);
|
||||
return Optional.ofNullable(response.getBody());
|
||||
} catch (RestClientException ex) {
|
||||
LOG.warn("Unable to fetch 7TV emotes from {}", url, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<CachedEmote> cacheEmote(SevenTvEmote emote) {
|
||||
if (emote == null || emote.id() == null || emote.id().isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(
|
||||
emoteCache.computeIfAbsent(
|
||||
emote.id(),
|
||||
(id) -> {
|
||||
String imageUrl = selectImageUrl(emote);
|
||||
if (imageUrl == null) {
|
||||
return null;
|
||||
}
|
||||
return downloadEmote(id, emote.name(), imageUrl);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private CachedEmote downloadEmote(String id, String name, String imageUrl) {
|
||||
Path filePath = resolveEmotePath(id, imageUrl);
|
||||
MediaType mediaType = null;
|
||||
|
||||
if (!Files.exists(filePath)) {
|
||||
try {
|
||||
ResponseEntity<byte[]> response = restTemplate.getForEntity(URI.create(imageUrl), byte[].class);
|
||||
byte[] bytes = response.getBody();
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
mediaType = response.getHeaders().getContentType();
|
||||
Files.write(filePath, bytes);
|
||||
} catch (IOException | RestClientException ex) {
|
||||
LOG.warn("Unable to download 7TV emote {}", id, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaType == null) {
|
||||
mediaType = mediaTypeFromPath(filePath);
|
||||
}
|
||||
return new CachedEmote(id, name, filePath, mediaType != null ? mediaType.toString() : "image/png");
|
||||
}
|
||||
|
||||
private Optional<CachedEmote> restoreFromDisk(String id) {
|
||||
if (id == null || id.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
List<Path> candidates;
|
||||
try (var stream = Files.list(cacheRoot)) {
|
||||
candidates = stream.filter((path) -> path.getFileName().toString().startsWith(id + ".")).toList();
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path path = candidates.get(0);
|
||||
MediaType mediaType = mediaTypeFromPath(path);
|
||||
CachedEmote cached = new CachedEmote(id, id, path, mediaType != null ? mediaType.toString() : "image/png");
|
||||
emoteCache.put(id, cached);
|
||||
return Optional.of(cached);
|
||||
} catch (IOException ex) {
|
||||
LOG.warn("Unable to restore cached 7TV emote {}", id, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveEmotePath(String id, String imageUrl) {
|
||||
String extension = extensionFromUrl(imageUrl).orElse("png");
|
||||
return cacheRoot.resolve(id + "." + extension);
|
||||
}
|
||||
|
||||
private Optional<String> extensionFromUrl(String imageUrl) {
|
||||
if (imageUrl == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
String path = URI.create(imageUrl).getPath();
|
||||
int dot = path.lastIndexOf('.');
|
||||
if (dot == -1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(path.substring(dot + 1));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private MediaType mediaTypeFromPath(Path path) {
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
String name = path.getFileName().toString().toLowerCase(Locale.ROOT);
|
||||
if (name.endsWith(".gif")) {
|
||||
return MediaType.IMAGE_GIF;
|
||||
}
|
||||
if (name.endsWith(".webp")) {
|
||||
return MediaType.parseMediaType("image/webp");
|
||||
}
|
||||
if (name.endsWith(".avif")) {
|
||||
return MediaType.parseMediaType("image/avif");
|
||||
}
|
||||
if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
|
||||
return MediaType.IMAGE_JPEG;
|
||||
}
|
||||
return MediaType.IMAGE_PNG;
|
||||
}
|
||||
|
||||
private String selectImageUrl(SevenTvEmote emote) {
|
||||
if (emote == null || emote.data() == null || emote.data().host() == null) {
|
||||
return null;
|
||||
}
|
||||
SevenTvHost host = emote.data().host();
|
||||
if (host.files() == null || host.files().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
SevenTvFile selected = selectBestFile(host.files());
|
||||
if (selected == null || selected.name() == null || selected.name().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String hostUrl = normalizeHostUrl(host.url());
|
||||
if (hostUrl == null) {
|
||||
return null;
|
||||
}
|
||||
return hostUrl + "/" + selected.name();
|
||||
}
|
||||
|
||||
private SevenTvFile selectBestFile(List<SevenTvFile> files) {
|
||||
if (files == null || files.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (String size : List.of("4x", "3x", "2x", "1x")) {
|
||||
Optional<SevenTvFile> match = files.stream().filter((file) -> size.equals(file.size())).findFirst();
|
||||
if (match.isPresent()) {
|
||||
return match.get();
|
||||
}
|
||||
}
|
||||
return files.get(0);
|
||||
}
|
||||
|
||||
private String normalizeHostUrl(String url) {
|
||||
if (url == null || url.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = url;
|
||||
if (normalized.startsWith("//")) {
|
||||
normalized = "https:" + normalized;
|
||||
} else if (!normalized.startsWith("http")) {
|
||||
normalized = "https://" + normalized;
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public record EmoteDescriptor(String id, String name, String url) {}
|
||||
|
||||
public record EmoteAsset(byte[] bytes, String mediaType) {}
|
||||
|
||||
private record CachedEmote(String id, String name, Path path, String mediaType) {
|
||||
EmoteDescriptor descriptor() {
|
||||
return new EmoteDescriptor(id, name, "/api/7tv/emotes/" + id);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record SevenTvUserResponse(@JsonProperty("emote_set") SevenTvEmoteSet emoteSet) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record SevenTvEmoteSet(List<SevenTvEmote> emotes) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record SevenTvEmote(String id, String name, SevenTvEmoteData data) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record SevenTvEmoteData(SevenTvHost host) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record SevenTvHost(String url, List<SevenTvFile> files) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record SevenTvFile(String name, String format, String size) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchUsersResponse(List<TwitchUserData> data) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchUserData(String id) {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE channels ADD COLUMN allow_7tv_emotes_for_assets BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
UPDATE channels
|
||||
SET allow_7tv_emotes_for_assets = TRUE
|
||||
WHERE allow_7tv_emotes_for_assets IS NULL;
|
||||
@@ -9,6 +9,7 @@ setUpElectronWindowFrame();
|
||||
const renderer = new BroadcastRenderer({ canvas, scriptLayer, broadcaster, showToast });
|
||||
const defaultScriptSettings = {
|
||||
allowChannelEmotesForAssets: true,
|
||||
allowSevenTvEmotesForAssets: true,
|
||||
allowScriptChatAccess: true,
|
||||
};
|
||||
let currentScriptSettings = { ...defaultScriptSettings };
|
||||
@@ -29,15 +30,33 @@ const settingsPromise = fetch(`/api/channels/${encodeURIComponent(broadcaster)}/
|
||||
renderer.setScriptSettings(defaultScriptSettings);
|
||||
});
|
||||
|
||||
fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`)
|
||||
const emoteCatalog = { global: [], channel: [], sevenTv: [] };
|
||||
const twitchEmotePromise = fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load Twitch emotes");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((catalog) => renderer.setEmoteCatalog(catalog))
|
||||
.then((catalog) => {
|
||||
emoteCatalog.global = Array.isArray(catalog?.global) ? catalog.global : [];
|
||||
emoteCatalog.channel = Array.isArray(catalog?.channel) ? catalog.channel : [];
|
||||
})
|
||||
.catch((error) => console.warn("Unable to load Twitch emotes", error));
|
||||
|
||||
const sevenTvEmotePromise = fetch(`/api/7tv/emotes?channel=${encodeURIComponent(broadcaster)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load 7TV emotes");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((catalog) => {
|
||||
emoteCatalog.sevenTv = Array.isArray(catalog?.channel) ? catalog.channel : [];
|
||||
})
|
||||
.catch((error) => console.warn("Unable to load 7TV emotes", error));
|
||||
|
||||
Promise.allSettled([twitchEmotePromise, sevenTvEmotePromise]).then(() => renderer.setEmoteCatalog(emoteCatalog));
|
||||
let disconnectChat = () => {};
|
||||
settingsPromise.finally(() => {
|
||||
if (!currentScriptSettings.allowScriptChatAccess) {
|
||||
|
||||
@@ -30,8 +30,11 @@ export class BroadcastRenderer {
|
||||
this.emoteCatalogById = new Map();
|
||||
this.globalEmotes = [];
|
||||
this.channelEmotes = [];
|
||||
this.sevenTvEmotes = [];
|
||||
this.sevenTvEmotesByName = new Map();
|
||||
this.lastChatPruneAt = 0;
|
||||
this.allowChannelEmotesForAssets = true;
|
||||
this.allowSevenTvEmotesForAssets = true;
|
||||
this.allowScriptChatAccess = true;
|
||||
|
||||
this.obsBrowser = !!globalThis.obsstudio;
|
||||
@@ -429,6 +432,7 @@ export class BroadcastRenderer {
|
||||
|
||||
setScriptSettings(settings) {
|
||||
this.allowChannelEmotesForAssets = settings?.allowChannelEmotesForAssets !== false;
|
||||
this.allowSevenTvEmotesForAssets = settings?.allowSevenTvEmotesForAssets !== false;
|
||||
this.allowScriptChatAccess = settings?.allowScriptChatAccess !== false;
|
||||
if (!this.allowScriptChatAccess) {
|
||||
this.chatMessages = [];
|
||||
@@ -478,15 +482,22 @@ export class BroadcastRenderer {
|
||||
setEmoteCatalog(catalog) {
|
||||
this.globalEmotes = Array.isArray(catalog?.global) ? catalog.global : [];
|
||||
this.channelEmotes = Array.isArray(catalog?.channel) ? catalog.channel : [];
|
||||
this.sevenTvEmotes = Array.isArray(catalog?.sevenTv) ? catalog.sevenTv : [];
|
||||
this.refreshEmoteCatalog();
|
||||
}
|
||||
|
||||
refreshEmoteCatalog() {
|
||||
const allowedChannelEmotes = this.allowChannelEmotesForAssets ? this.channelEmotes : [];
|
||||
this.emoteCatalog = [...this.globalEmotes, ...allowedChannelEmotes];
|
||||
const allowedSevenTvEmotes = this.allowSevenTvEmotesForAssets ? this.sevenTvEmotes : [];
|
||||
this.emoteCatalog = [...this.globalEmotes, ...allowedChannelEmotes, ...allowedSevenTvEmotes];
|
||||
this.emoteCatalogById = new Map(
|
||||
this.emoteCatalog.map((entry) => [String(entry?.id || ""), entry]).filter(([key]) => key),
|
||||
);
|
||||
this.sevenTvEmotesByName = new Map(
|
||||
allowedSevenTvEmotes
|
||||
.map((entry) => [String(entry?.name || ""), entry])
|
||||
.filter(([key]) => key),
|
||||
);
|
||||
if (this.chatMessages.length) {
|
||||
this.chatMessages = this.chatMessages.map((message) => {
|
||||
const fragments = this.buildMessageFragments(message.message || "", message.tags);
|
||||
@@ -534,7 +545,7 @@ export class BroadcastRenderer {
|
||||
}
|
||||
const emotes = this.parseEmoteOffsets(tags?.emotes);
|
||||
if (!emotes.length) {
|
||||
return [{ type: "text", text: message }];
|
||||
return this.applySevenTvEmotes([{ type: "text", text: message }]);
|
||||
}
|
||||
const sorted = emotes.sort((a, b) => a.start - b.start);
|
||||
const fragments = [];
|
||||
@@ -561,7 +572,43 @@ export class BroadcastRenderer {
|
||||
if (cursor < message.length) {
|
||||
fragments.push({ type: "text", text: message.slice(cursor) });
|
||||
}
|
||||
return fragments;
|
||||
return this.applySevenTvEmotes(fragments);
|
||||
}
|
||||
|
||||
applySevenTvEmotes(fragments) {
|
||||
if (!this.sevenTvEmotesByName.size) {
|
||||
return fragments;
|
||||
}
|
||||
const enhanced = [];
|
||||
fragments.forEach((fragment) => {
|
||||
if (fragment?.type !== "text" || !fragment.text) {
|
||||
enhanced.push(fragment);
|
||||
return;
|
||||
}
|
||||
const parts = fragment.text.split(/(\\s+)/);
|
||||
parts.forEach((part) => {
|
||||
if (!part) {
|
||||
return;
|
||||
}
|
||||
if (/^\\s+$/.test(part)) {
|
||||
enhanced.push({ type: "text", text: part });
|
||||
return;
|
||||
}
|
||||
const emote = this.sevenTvEmotesByName.get(part);
|
||||
if (emote) {
|
||||
enhanced.push({
|
||||
type: "emote",
|
||||
id: emote.id,
|
||||
text: part,
|
||||
name: emote.name || part,
|
||||
url: emote.url || null,
|
||||
});
|
||||
} else {
|
||||
enhanced.push({ type: "text", text: part });
|
||||
}
|
||||
});
|
||||
});
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
receiveChatMessage(message) {
|
||||
|
||||
@@ -8,6 +8,7 @@ const elements = {
|
||||
canvasStatus: document.getElementById("canvas-status"),
|
||||
canvasSaveButton: document.getElementById("save-canvas-btn"),
|
||||
allowChannelEmotes: document.getElementById("allow-channel-emotes"),
|
||||
allowSevenTvEmotes: document.getElementById("allow-7tv-emotes"),
|
||||
allowScriptChat: document.getElementById("allow-script-chat"),
|
||||
scriptSettingsStatus: document.getElementById("script-settings-status"),
|
||||
scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"),
|
||||
@@ -250,6 +251,9 @@ function renderScriptSettings(settings) {
|
||||
if (elements.allowChannelEmotes) {
|
||||
elements.allowChannelEmotes.checked = settings.allowChannelEmotesForAssets !== false;
|
||||
}
|
||||
if (elements.allowSevenTvEmotes) {
|
||||
elements.allowSevenTvEmotes.checked = settings.allowSevenTvEmotesForAssets !== false;
|
||||
}
|
||||
if (elements.allowScriptChat) {
|
||||
elements.allowScriptChat.checked = settings.allowScriptChatAccess !== false;
|
||||
}
|
||||
@@ -260,13 +264,18 @@ async function fetchScriptSettings() {
|
||||
const data = await fetchJson("/settings", {}, "Failed to load script settings");
|
||||
renderScriptSettings(data);
|
||||
} catch (error) {
|
||||
renderScriptSettings({ allowChannelEmotesForAssets: true, allowScriptChatAccess: true });
|
||||
renderScriptSettings({
|
||||
allowChannelEmotesForAssets: true,
|
||||
allowSevenTvEmotesForAssets: true,
|
||||
allowScriptChatAccess: true,
|
||||
});
|
||||
showToast("Using default script settings. Unable to load saved preferences.", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScriptSettings() {
|
||||
const allowChannelEmotesForAssets = elements.allowChannelEmotes?.checked ?? true;
|
||||
const allowSevenTvEmotesForAssets = elements.allowSevenTvEmotes?.checked ?? true;
|
||||
const allowScriptChatAccess = elements.allowScriptChat?.checked ?? true;
|
||||
if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Saving...";
|
||||
setButtonBusy(elements.scriptSettingsSaveButton, true, "Saving...");
|
||||
@@ -276,7 +285,11 @@ async function saveScriptSettings() {
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowChannelEmotesForAssets, allowScriptChatAccess }),
|
||||
body: JSON.stringify({
|
||||
allowChannelEmotesForAssets,
|
||||
allowSevenTvEmotesForAssets,
|
||||
allowScriptChatAccess,
|
||||
}),
|
||||
},
|
||||
"Failed to save script settings",
|
||||
);
|
||||
|
||||
@@ -94,6 +94,10 @@
|
||||
<input id="allow-channel-emotes" type="checkbox" checked />
|
||||
Allow script assets to use this channel's Twitch emotes.
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input id="allow-7tv-emotes" type="checkbox" checked />
|
||||
Allow script assets to use this channel's 7TV emotes.
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input id="allow-script-chat" type="checkbox" checked />
|
||||
Allow script assets to access this channel's Twitch chat log.
|
||||
|
||||
Reference in New Issue
Block a user