Make the twitch chat widget more configurable
This commit is contained in:
parent
0842dccf73
commit
1218e601ae
5 changed files with 509 additions and 23 deletions
|
|
@ -187,6 +187,8 @@
|
||||||
|
|
||||||
const theme = document.getElementById('livechat-theme').value;
|
const theme = document.getElementById('livechat-theme').value;
|
||||||
const direction = document.getElementById('livechat-direction').value;
|
const direction = document.getElementById('livechat-direction').value;
|
||||||
|
const fontsize = document.getElementById('livechat-fontsize').value;
|
||||||
|
const hidetime = document.getElementById('livechat-hidetime').value;
|
||||||
const urlInput = document.getElementById('livechat-url');
|
const urlInput = document.getElementById('livechat-url');
|
||||||
const openLink = document.getElementById('livechat-open');
|
const openLink = document.getElementById('livechat-open');
|
||||||
|
|
||||||
|
|
@ -195,6 +197,8 @@
|
||||||
|
|
||||||
if (theme !== 'dark') params.push(`theme=${theme}`);
|
if (theme !== 'dark') params.push(`theme=${theme}`);
|
||||||
if (direction !== 'down') params.push(`direction=${direction}`);
|
if (direction !== 'down') params.push(`direction=${direction}`);
|
||||||
|
if (fontsize !== 'medium') params.push(`fontsize=${fontsize}`);
|
||||||
|
if (hidetime === 'true') params.push(`hidetime=true`);
|
||||||
|
|
||||||
if (params.length > 0) {
|
if (params.length > 0) {
|
||||||
url += '?' + params.join('&');
|
url += '?' + params.join('&');
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,16 @@ class LiveChatWidget {
|
||||||
document.body.classList.add('direction-up');
|
document.body.classList.add('direction-up');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Font size: small, medium (default), large, xlarge
|
||||||
|
const fontSize = urlParams.get('fontsize') || 'medium';
|
||||||
|
document.body.classList.add(`font-${fontSize}`);
|
||||||
|
|
||||||
|
// Hide timestamp option
|
||||||
|
const hideTime = urlParams.get('hidetime');
|
||||||
|
if (hideTime === 'true' || hideTime === '1') {
|
||||||
|
document.body.classList.add('hide-time');
|
||||||
|
}
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,40 @@ body.theme-dark .chat-message {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Font size options */
|
||||||
|
body.font-small {
|
||||||
|
--chat-font-size: 12px;
|
||||||
|
--chat-username-size: 12px;
|
||||||
|
--chat-badge-size: 14px;
|
||||||
|
--chat-emote-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.font-medium {
|
||||||
|
--chat-font-size: 14px;
|
||||||
|
--chat-username-size: 14px;
|
||||||
|
--chat-badge-size: 16px;
|
||||||
|
--chat-emote-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.font-large {
|
||||||
|
--chat-font-size: 18px;
|
||||||
|
--chat-username-size: 18px;
|
||||||
|
--chat-badge-size: 20px;
|
||||||
|
--chat-emote-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.font-xlarge {
|
||||||
|
--chat-font-size: 24px;
|
||||||
|
--chat-username-size: 24px;
|
||||||
|
--chat-badge-size: 26px;
|
||||||
|
--chat-emote-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide timestamp option */
|
||||||
|
body.hide-time .timestamp {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
#chat-container {
|
#chat-container {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
@ -53,7 +87,7 @@ body.theme-dark .chat-message {
|
||||||
|
|
||||||
#chat-messages {
|
#chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: hidden;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -168,8 +202,8 @@ body.direction-up .chat-message {
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
width: 18px;
|
width: var(--chat-badge-size, 18px);
|
||||||
height: 18px;
|
height: var(--chat-badge-size, 18px);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -190,19 +224,19 @@ body.direction-up .chat-message {
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 14px;
|
font-size: var(--chat-username-size, 14px);
|
||||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
font-size: 11px;
|
font-size: calc(var(--chat-font-size, 14px) * 0.8);
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message text */
|
/* Message text */
|
||||||
.message-text {
|
.message-text {
|
||||||
font-size: 14px;
|
font-size: var(--chat-font-size, 14px);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
|
|
@ -213,8 +247,10 @@ body.direction-up .chat-message {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
max-height: 28px;
|
height: var(--chat-emote-size, 28px);
|
||||||
max-width: 28px;
|
width: auto;
|
||||||
|
max-width: calc(var(--chat-emote-size, 28px) * 3);
|
||||||
|
object-fit: contain;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,57 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from app.chat_models import ChatBadge, ChatMessage, ChatUser, Emote, Platform, UserRole
|
from app.chat_models import ChatBadge, ChatMessage, ChatUser, Emote, Platform, UserRole
|
||||||
|
from app.paths import get_data_dir
|
||||||
from app.state import AppState
|
from app.state import AppState
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
BTTV_TOP_CACHE_FILE = "bttv_top_emotes.json"
|
||||||
|
BTTV_TRENDING_CACHE_FILE = "bttv_trending_emotes.json"
|
||||||
|
SEVENTV_TOP_CACHE_FILE = "7tv_top_emotes.json"
|
||||||
|
SEVENTV_TRENDING_CACHE_FILE = "7tv_trending_emotes.json"
|
||||||
|
EMOTE_CACHE_MAX_AGE = timedelta(hours=24) # Refresh cache after 24 hours
|
||||||
|
TRENDING_CACHE_MAX_AGE = timedelta(hours=6) # Refresh trending more frequently
|
||||||
|
|
||||||
|
# 7TV GraphQL query for emote search (supports different sort options)
|
||||||
|
SEVENTV_EMOTES_QUERY = """
|
||||||
|
query EmoteSearch($page: Int, $perPage: Int!, $sortBy: SortBy!) {
|
||||||
|
emotes {
|
||||||
|
search(
|
||||||
|
query: null
|
||||||
|
tags: {tags: [], match: ANY}
|
||||||
|
sort: {sortBy: $sortBy, order: DESCENDING}
|
||||||
|
filters: {}
|
||||||
|
page: $page
|
||||||
|
perPage: $perPage
|
||||||
|
) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
defaultName
|
||||||
|
images {
|
||||||
|
url
|
||||||
|
mime
|
||||||
|
size
|
||||||
|
scale
|
||||||
|
width
|
||||||
|
frameCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalCount
|
||||||
|
pageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class TwitchChatClient:
|
class TwitchChatClient:
|
||||||
"""
|
"""
|
||||||
|
|
@ -378,6 +420,9 @@ class TwitchChatClient:
|
||||||
if not self.session:
|
if not self.session:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
loaded_global = 0
|
||||||
|
loaded_channel = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Global FFZ emotes
|
# Global FFZ emotes
|
||||||
async with self.session.get("https://api.frankerfacez.com/v1/set/global") as resp:
|
async with self.session.get("https://api.frankerfacez.com/v1/set/global") as resp:
|
||||||
|
|
@ -392,6 +437,9 @@ class TwitchChatClient:
|
||||||
self.global_emotes[code] = Emote(
|
self.global_emotes[code] = Emote(
|
||||||
code=code, url=f"https:{url}" if url.startswith("//") else url, provider="ffz"
|
code=code, url=f"https:{url}" if url.startswith("//") else url, provider="ffz"
|
||||||
)
|
)
|
||||||
|
loaded_global += 1
|
||||||
|
|
||||||
|
print(f"FFZ: Loaded {loaded_global} global emotes")
|
||||||
|
|
||||||
# Channel-specific FFZ emotes
|
# Channel-specific FFZ emotes
|
||||||
async with self.session.get(f"https://api.frankerfacez.com/v1/room/{self.channel}") as resp:
|
async with self.session.get(f"https://api.frankerfacez.com/v1/room/{self.channel}") as resp:
|
||||||
|
|
@ -406,14 +454,128 @@ class TwitchChatClient:
|
||||||
self.channel_emotes[code] = Emote(
|
self.channel_emotes[code] = Emote(
|
||||||
code=code, url=f"https:{url}" if url.startswith("//") else url, provider="ffz"
|
code=code, url=f"https:{url}" if url.startswith("//") else url, provider="ffz"
|
||||||
)
|
)
|
||||||
|
loaded_channel += 1
|
||||||
|
|
||||||
|
if loaded_channel > 0:
|
||||||
|
print(f"FFZ: Loaded {loaded_channel} channel emotes")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"FFZ emote load error: {e}")
|
print(f"FFZ emote load error: {e}")
|
||||||
|
|
||||||
|
def _get_bttv_cache_path(self, cache_type: str = "top") -> Path:
|
||||||
|
"""Get the path to the BTTV emote cache file."""
|
||||||
|
if cache_type == "trending":
|
||||||
|
return get_data_dir() / BTTV_TRENDING_CACHE_FILE
|
||||||
|
return get_data_dir() / BTTV_TOP_CACHE_FILE
|
||||||
|
|
||||||
|
def _is_bttv_cache_valid(self, cache_type: str = "top") -> bool:
|
||||||
|
"""Check if the BTTV cache exists and is not expired."""
|
||||||
|
cache_path = self._get_bttv_cache_path(cache_type)
|
||||||
|
if not cache_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check cache age - trending refreshes more frequently
|
||||||
|
cache_mtime = datetime.fromtimestamp(cache_path.stat().st_mtime)
|
||||||
|
max_age = TRENDING_CACHE_MAX_AGE if cache_type == "trending" else EMOTE_CACHE_MAX_AGE
|
||||||
|
return datetime.now() - cache_mtime < max_age
|
||||||
|
|
||||||
|
def _load_bttv_cache(self, cache_type: str = "top") -> list[dict]:
|
||||||
|
"""Load BTTV emotes from cache file."""
|
||||||
|
cache_path = self._get_bttv_cache_path(cache_type)
|
||||||
|
try:
|
||||||
|
with open(cache_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"BTTV: Error loading {cache_type} cache: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save_bttv_cache(self, emotes: list[dict], cache_type: str = "top") -> None:
|
||||||
|
"""Save BTTV emotes to cache file."""
|
||||||
|
cache_path = self._get_bttv_cache_path(cache_type)
|
||||||
|
try:
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(cache_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(emotes, f)
|
||||||
|
print(f"BTTV: Saved {len(emotes)} {cache_type} emotes to cache")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"BTTV: Error saving {cache_type} cache: {e}")
|
||||||
|
|
||||||
|
async def _fetch_bttv_emotes_by_type(self, emote_type: str, label: str, max_pages: int = 100) -> list[dict]:
|
||||||
|
"""Fetch BTTV emotes by paginating through the API with a specific type (top/trending)."""
|
||||||
|
if not self.session:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_emotes: list[dict] = []
|
||||||
|
before_cursor: Optional[str] = None
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
print(f"BTTV: Fetching {label} shared emotes...")
|
||||||
|
|
||||||
|
while page <= max_pages:
|
||||||
|
url = f"https://api.betterttv.net/3/emotes/shared/{emote_type}?limit=100"
|
||||||
|
if before_cursor:
|
||||||
|
url += f"&before={before_cursor}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self.session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
print(f"BTTV: Error fetching {label} page {page}: status {resp.status}")
|
||||||
|
break
|
||||||
|
|
||||||
|
emotes = await resp.json()
|
||||||
|
if not emotes:
|
||||||
|
break # No more emotes
|
||||||
|
|
||||||
|
all_emotes.extend(emotes)
|
||||||
|
|
||||||
|
# Get the cursor for next page from the last item
|
||||||
|
last_item = emotes[-1]
|
||||||
|
before_cursor = last_item.get("id")
|
||||||
|
|
||||||
|
if not before_cursor:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Log every 10 pages to reduce spam
|
||||||
|
if page % 10 == 0 or page == 1:
|
||||||
|
print(f"BTTV: Fetched {label} page {page} (total: {len(all_emotes)})")
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
# Small delay to be nice to the API
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"BTTV: Error fetching {label} page {page}: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"BTTV: Finished fetching {len(all_emotes)} {label} emotes")
|
||||||
|
return all_emotes
|
||||||
|
|
||||||
|
def _load_bttv_emotes_to_dict(self, emotes: list[dict]) -> int:
|
||||||
|
"""Load BTTV emotes from a list into the global emotes dictionary."""
|
||||||
|
loaded = 0
|
||||||
|
for item in emotes:
|
||||||
|
emote = item.get("emote", {})
|
||||||
|
code = emote.get("code")
|
||||||
|
emote_id = emote.get("id")
|
||||||
|
if code and emote_id and code not in self.global_emotes:
|
||||||
|
self.global_emotes[code] = Emote(
|
||||||
|
code=code,
|
||||||
|
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
||||||
|
provider="bttv",
|
||||||
|
)
|
||||||
|
loaded += 1
|
||||||
|
return loaded
|
||||||
|
|
||||||
async def _load_bttv_emotes(self) -> None:
|
async def _load_bttv_emotes(self) -> None:
|
||||||
"""Load BetterTTV emotes."""
|
"""Load BetterTTV emotes."""
|
||||||
if not self.session:
|
if not self.session:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
loaded_global = 0
|
||||||
|
loaded_channel = 0
|
||||||
|
loaded_top = 0
|
||||||
|
loaded_trending = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Global BTTV emotes
|
# Global BTTV emotes
|
||||||
async with self.session.get("https://api.betterttv.net/3/cached/emotes/global") as resp:
|
async with self.session.get("https://api.betterttv.net/3/cached/emotes/global") as resp:
|
||||||
|
|
@ -428,9 +590,40 @@ class TwitchChatClient:
|
||||||
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
||||||
provider="bttv",
|
provider="bttv",
|
||||||
)
|
)
|
||||||
|
loaded_global += 1
|
||||||
|
|
||||||
# Channel BTTV emotes
|
print(f"BTTV: Loaded {loaded_global} global emotes")
|
||||||
async with self.session.get(f"https://api.betterttv.net/3/cached/users/twitch/{self.channel}") as resp:
|
|
||||||
|
# Top shared BTTV emotes - use cache if valid, otherwise fetch all
|
||||||
|
if self._is_bttv_cache_valid("top"):
|
||||||
|
print("BTTV: Using cached top emotes")
|
||||||
|
top_emotes = self._load_bttv_cache("top")
|
||||||
|
else:
|
||||||
|
top_emotes = await self._fetch_bttv_emotes_by_type("top", "top")
|
||||||
|
if top_emotes:
|
||||||
|
self._save_bttv_cache(top_emotes, "top")
|
||||||
|
|
||||||
|
loaded_top = self._load_bttv_emotes_to_dict(top_emotes)
|
||||||
|
if loaded_top > 0:
|
||||||
|
print(f"BTTV: Loaded {loaded_top} top shared emotes")
|
||||||
|
|
||||||
|
# Trending BTTV emotes - use cache if valid, otherwise fetch
|
||||||
|
# Trending typically has fewer pages, limit to 50
|
||||||
|
if self._is_bttv_cache_valid("trending"):
|
||||||
|
print("BTTV: Using cached trending emotes")
|
||||||
|
trending_emotes = self._load_bttv_cache("trending")
|
||||||
|
else:
|
||||||
|
trending_emotes = await self._fetch_bttv_emotes_by_type("trending", "trending", max_pages=50)
|
||||||
|
if trending_emotes:
|
||||||
|
self._save_bttv_cache(trending_emotes, "trending")
|
||||||
|
|
||||||
|
loaded_trending = self._load_bttv_emotes_to_dict(trending_emotes)
|
||||||
|
if loaded_trending > 0:
|
||||||
|
print(f"BTTV: Loaded {loaded_trending} trending emotes")
|
||||||
|
|
||||||
|
# Channel BTTV emotes - use channel ID if available
|
||||||
|
channel_identifier = self.channel_id or self.channel
|
||||||
|
async with self.session.get(f"https://api.betterttv.net/3/cached/users/twitch/{channel_identifier}") as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
for emote in data.get("channelEmotes", []) + data.get("sharedEmotes", []):
|
for emote in data.get("channelEmotes", []) + data.get("sharedEmotes", []):
|
||||||
|
|
@ -442,14 +635,200 @@ class TwitchChatClient:
|
||||||
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
||||||
provider="bttv",
|
provider="bttv",
|
||||||
)
|
)
|
||||||
|
loaded_channel += 1
|
||||||
|
|
||||||
|
if loaded_channel > 0:
|
||||||
|
print(f"BTTV: Loaded {loaded_channel} channel emotes")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"BTTV emote load error: {e}")
|
print(f"BTTV emote load error: {e}")
|
||||||
|
|
||||||
|
def _get_7tv_emote_url(self, emote: dict) -> Optional[str]:
|
||||||
|
"""Extract the correct URL from a 7TV emote object."""
|
||||||
|
# Try to get from data.host structure (v3 API format)
|
||||||
|
emote_data = emote.get("data", {})
|
||||||
|
host = emote_data.get("host", {})
|
||||||
|
|
||||||
|
if host:
|
||||||
|
base_url = host.get("url", "")
|
||||||
|
files = host.get("files", [])
|
||||||
|
|
||||||
|
# Find best quality webp file
|
||||||
|
for f in files:
|
||||||
|
if f.get("name") == "1x.webp":
|
||||||
|
return f"https:{base_url}/{f.get('name')}"
|
||||||
|
|
||||||
|
# Fallback to first webp file
|
||||||
|
for f in files:
|
||||||
|
if f.get("format") == "WEBP":
|
||||||
|
return f"https:{base_url}/{f.get('name')}"
|
||||||
|
|
||||||
|
# Last resort: construct URL
|
||||||
|
if base_url:
|
||||||
|
return f"https:{base_url}/1x.webp"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_7tv_emote_url_v4(self, emote: dict) -> Optional[str]:
|
||||||
|
"""Extract the correct URL from a 7TV v4 GraphQL emote object."""
|
||||||
|
images = emote.get("images", [])
|
||||||
|
|
||||||
|
# Find 1x scale image
|
||||||
|
for img in images:
|
||||||
|
if img.get("scale") == 1:
|
||||||
|
url = img.get("url")
|
||||||
|
if url:
|
||||||
|
return url if url.startswith("http") else f"https:{url}"
|
||||||
|
|
||||||
|
# Fallback to first image
|
||||||
|
if images:
|
||||||
|
url = images[0].get("url")
|
||||||
|
if url:
|
||||||
|
return url if url.startswith("http") else f"https:{url}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_7tv_cache_path(self, cache_type: str = "top") -> Path:
|
||||||
|
"""Get the path to the 7TV emote cache file."""
|
||||||
|
if cache_type == "trending":
|
||||||
|
return get_data_dir() / SEVENTV_TRENDING_CACHE_FILE
|
||||||
|
return get_data_dir() / SEVENTV_TOP_CACHE_FILE
|
||||||
|
|
||||||
|
def _is_7tv_cache_valid(self, cache_type: str = "top") -> bool:
|
||||||
|
"""Check if the 7TV cache exists and is not expired."""
|
||||||
|
cache_path = self._get_7tv_cache_path(cache_type)
|
||||||
|
if not cache_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check cache age - trending refreshes more frequently
|
||||||
|
cache_mtime = datetime.fromtimestamp(cache_path.stat().st_mtime)
|
||||||
|
max_age = TRENDING_CACHE_MAX_AGE if cache_type == "trending" else EMOTE_CACHE_MAX_AGE
|
||||||
|
return datetime.now() - cache_mtime < max_age
|
||||||
|
|
||||||
|
def _load_7tv_cache(self, cache_type: str = "top") -> list[dict]:
|
||||||
|
"""Load 7TV emotes from cache file."""
|
||||||
|
cache_path = self._get_7tv_cache_path(cache_type)
|
||||||
|
try:
|
||||||
|
with open(cache_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"7TV: Error loading {cache_type} cache: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save_7tv_cache(self, emotes: list[dict], cache_type: str = "top") -> None:
|
||||||
|
"""Save 7TV emotes to cache file."""
|
||||||
|
cache_path = self._get_7tv_cache_path(cache_type)
|
||||||
|
try:
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(cache_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(emotes, f)
|
||||||
|
print(f"7TV: Saved {len(emotes)} {cache_type} emotes to cache")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"7TV: Error saving {cache_type} cache: {e}")
|
||||||
|
|
||||||
|
async def _fetch_7tv_emotes_by_sort(self, sort_by: str, label: str, max_pages: int = 150) -> list[dict]:
|
||||||
|
"""Fetch 7TV emotes by paginating through the GraphQL API with a specific sort."""
|
||||||
|
if not self.session:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_emotes: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
per_page = 72 # Max per page for 7TV
|
||||||
|
total_pages = None
|
||||||
|
|
||||||
|
print(f"7TV: Fetching {label} emotes...")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Origin": "https://7tv.app",
|
||||||
|
"Referer": "https://7tv.app/",
|
||||||
|
}
|
||||||
|
|
||||||
|
while page <= max_pages:
|
||||||
|
if total_pages is not None and page > total_pages:
|
||||||
|
break
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"operationName": "EmoteSearch",
|
||||||
|
"query": SEVENTV_EMOTES_QUERY,
|
||||||
|
"variables": {
|
||||||
|
"page": page,
|
||||||
|
"perPage": per_page,
|
||||||
|
"sortBy": sort_by,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self.session.post(
|
||||||
|
"https://api.7tv.app/v4/gql",
|
||||||
|
json=payload,
|
||||||
|
headers=headers
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
print(f"7TV: Error fetching {label} page {page}: status {resp.status}")
|
||||||
|
break
|
||||||
|
|
||||||
|
result = await resp.json()
|
||||||
|
search_data = result.get("data", {}).get("emotes", {}).get("search", {})
|
||||||
|
items = search_data.get("items", [])
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
break # No more emotes
|
||||||
|
|
||||||
|
all_emotes.extend(items)
|
||||||
|
|
||||||
|
# Get total page count on first request
|
||||||
|
if total_pages is None:
|
||||||
|
total_pages = min(search_data.get("pageCount", max_pages), max_pages)
|
||||||
|
total_count = search_data.get("totalCount", 0)
|
||||||
|
print(f"7TV: Found {total_count:,} {label} emotes, fetching up to {total_pages} pages")
|
||||||
|
|
||||||
|
# Log every 10 pages to reduce spam
|
||||||
|
if page % 10 == 0 or page == 1:
|
||||||
|
print(f"7TV: Fetched {label} page {page}/{total_pages} (total: {len(all_emotes)})")
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
# Small delay to be nice to the API
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"7TV: Error fetching {label} page {page}: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"7TV: Finished fetching {len(all_emotes)} {label} emotes")
|
||||||
|
return all_emotes
|
||||||
|
|
||||||
|
def _load_7tv_emotes_to_dict(self, emotes: list[dict]) -> int:
|
||||||
|
"""Load 7TV emotes from a list into the global emotes dictionary."""
|
||||||
|
loaded = 0
|
||||||
|
for emote in emotes:
|
||||||
|
code = emote.get("defaultName")
|
||||||
|
if code and code not in self.global_emotes:
|
||||||
|
url = self._get_7tv_emote_url_v4(emote)
|
||||||
|
if url:
|
||||||
|
self.global_emotes[code] = Emote(
|
||||||
|
code=code,
|
||||||
|
url=url,
|
||||||
|
provider="7tv",
|
||||||
|
is_animated=any(
|
||||||
|
img.get("frameCount", 1) > 1
|
||||||
|
for img in emote.get("images", [])
|
||||||
|
),
|
||||||
|
)
|
||||||
|
loaded += 1
|
||||||
|
return loaded
|
||||||
|
|
||||||
async def _load_7tv_emotes(self) -> None:
|
async def _load_7tv_emotes(self) -> None:
|
||||||
"""Load 7TV emotes."""
|
"""Load 7TV emotes."""
|
||||||
if not self.session:
|
if not self.session:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
loaded_global = 0
|
||||||
|
loaded_channel = 0
|
||||||
|
loaded_top = 0
|
||||||
|
loaded_trending = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Global 7TV emotes
|
# Global 7TV emotes
|
||||||
async with self.session.get("https://7tv.io/v3/emote-sets/global") as resp:
|
async with self.session.get("https://7tv.io/v3/emote-sets/global") as resp:
|
||||||
|
|
@ -457,33 +836,74 @@ class TwitchChatClient:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
for emote in data.get("emotes", []):
|
for emote in data.get("emotes", []):
|
||||||
code = emote.get("name")
|
code = emote.get("name")
|
||||||
emote_data = emote.get("data", {})
|
url = self._get_7tv_emote_url(emote)
|
||||||
host = emote_data.get("host", {})
|
if code and url:
|
||||||
if code and host:
|
emote_data = emote.get("data", {})
|
||||||
url = f"https:{host.get('url', '')}/1x.webp"
|
|
||||||
self.global_emotes[code] = Emote(
|
self.global_emotes[code] = Emote(
|
||||||
code=code,
|
code=code,
|
||||||
url=url,
|
url=url,
|
||||||
provider="7tv",
|
provider="7tv",
|
||||||
is_animated=emote.get("animated", False),
|
is_animated=emote_data.get("animated", False),
|
||||||
)
|
)
|
||||||
|
loaded_global += 1
|
||||||
|
|
||||||
# Channel 7TV emotes
|
print(f"7TV: Loaded {loaded_global} global emotes")
|
||||||
async with self.session.get(f"https://7tv.io/v3/users/twitch/{self.channel}") as resp:
|
|
||||||
|
# Top 7TV emotes - use cache if valid, otherwise fetch all
|
||||||
|
if self._is_7tv_cache_valid("top"):
|
||||||
|
print("7TV: Using cached top emotes")
|
||||||
|
top_emotes = self._load_7tv_cache("top")
|
||||||
|
else:
|
||||||
|
top_emotes = await self._fetch_7tv_emotes_by_sort("TOP_ALL_TIME", "top")
|
||||||
|
if top_emotes:
|
||||||
|
self._save_7tv_cache(top_emotes, "top")
|
||||||
|
|
||||||
|
loaded_top = self._load_7tv_emotes_to_dict(top_emotes)
|
||||||
|
if loaded_top > 0:
|
||||||
|
print(f"7TV: Loaded {loaded_top} top emotes")
|
||||||
|
|
||||||
|
# Trending 7TV emotes - use cache if valid, otherwise fetch
|
||||||
|
# Trending has fewer pages typically, so limit to 50 pages
|
||||||
|
if self._is_7tv_cache_valid("trending"):
|
||||||
|
print("7TV: Using cached trending emotes")
|
||||||
|
trending_emotes = self._load_7tv_cache("trending")
|
||||||
|
else:
|
||||||
|
trending_emotes = await self._fetch_7tv_emotes_by_sort("TRENDING_MONTHLY", "trending", max_pages=50)
|
||||||
|
if trending_emotes:
|
||||||
|
self._save_7tv_cache(trending_emotes, "trending")
|
||||||
|
|
||||||
|
loaded_trending = self._load_7tv_emotes_to_dict(trending_emotes)
|
||||||
|
if loaded_trending > 0:
|
||||||
|
print(f"7TV: Loaded {loaded_trending} trending emotes")
|
||||||
|
|
||||||
|
# Channel 7TV emotes - try channel ID first, then username
|
||||||
|
channel_url = None
|
||||||
|
if self.channel_id:
|
||||||
|
channel_url = f"https://7tv.io/v3/users/twitch/{self.channel_id}"
|
||||||
|
else:
|
||||||
|
channel_url = f"https://7tv.io/v3/users/twitch/{self.channel}"
|
||||||
|
|
||||||
|
async with self.session.get(channel_url) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
emote_set = data.get("emote_set", {})
|
emote_set = data.get("emote_set", {})
|
||||||
for emote in emote_set.get("emotes", []):
|
for emote in emote_set.get("emotes", []):
|
||||||
code = emote.get("name")
|
code = emote.get("name")
|
||||||
emote_data = emote.get("data", {})
|
url = self._get_7tv_emote_url(emote)
|
||||||
host = emote_data.get("host", {})
|
if code and url:
|
||||||
if code and host:
|
emote_data = emote.get("data", {})
|
||||||
url = f"https:{host.get('url', '')}/1x.webp"
|
|
||||||
self.channel_emotes[code] = Emote(
|
self.channel_emotes[code] = Emote(
|
||||||
code=code,
|
code=code,
|
||||||
url=url,
|
url=url,
|
||||||
provider="7tv",
|
provider="7tv",
|
||||||
is_animated=emote.get("animated", False),
|
is_animated=emote_data.get("animated", False),
|
||||||
)
|
)
|
||||||
|
loaded_channel += 1
|
||||||
|
elif resp.status == 404:
|
||||||
|
print(f"7TV: No emotes found for channel {self.channel}")
|
||||||
|
|
||||||
|
if loaded_channel > 0:
|
||||||
|
print(f"7TV: Loaded {loaded_channel} channel emotes")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"7TV emote load error: {e}")
|
print(f"7TV emote load error: {e}")
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,22 @@ async def handle_root(request: web.Request) -> web.Response:
|
||||||
<option value="up">Up (bubbles up, newest anchored)</option>
|
<option value="up">Up (bubbles up, newest anchored)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label>Font Size</label>
|
||||||
|
<select id="livechat-fontsize" onchange="updateLiveChatUrl()">
|
||||||
|
<option value="small">Small</option>
|
||||||
|
<option value="medium" selected>Medium</option>
|
||||||
|
<option value="large">Large</option>
|
||||||
|
<option value="xlarge">Extra Large</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="option-group">
|
||||||
|
<label>Timestamp</label>
|
||||||
|
<select id="livechat-hidetime" onchange="updateLiveChatUrl()">
|
||||||
|
<option value="false">Show</option>
|
||||||
|
<option value="true">Hide</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue