From 44aa0efaec23902d5900ddece4cb607ffda6632c Mon Sep 17 00:00:00 2001 From: Arthur Kielbasa Date: Fri, 22 May 2026 16:17:54 +0200 Subject: [PATCH] ADD Tweitch connection ADD Twitch followlist ADD Twitch followlist filter ADD Twitch better ui for search streams ADD Remove stream from ui --- .gitignore | 2 + .idea/.gitignore | 10 + README.txt | 26 +++ development.ini | 7 +- multitwitch/__init__.py | 33 +++- multitwitch/config.py | 19 +- multitwitch/lib/session.py | 13 +- multitwitch/lib/twitch.py | 168 ++++++++++++++++ multitwitch/static/css/multitwitch.css | 233 ++++++++++++++++++++++- multitwitch/static/js/multitwitch.js | 253 +++++++++++++++++++++++-- multitwitch/templates/web/home.tmpl | 74 ++++++-- multitwitch/views/web.py | 203 +++++++++++++++++++- production.ini | 5 + 13 files changed, 999 insertions(+), 47 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 multitwitch/lib/twitch.py diff --git a/.gitignore b/.gitignore index 56e4076..6eea944 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.pyc multitwitch.egg-info +development.ini.local +production.ini.local diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/README.txt b/README.txt index 796dce0..2e6a2d6 100644 --- a/README.txt +++ b/README.txt @@ -3,3 +3,29 @@ Multitwtich -- Multiple twitch streams on one page. The code of this project is free to use. Contact me at brian.c.hamrick AT gmail.com + +Configuration +------------- + +Keep public-safe placeholders in development.ini and production.ini. +Put real local values in an ignored development.ini.local or +production.ini.local file. + +Local development +----------------- + +Create development.ini.local: + +[app:main] +multitwitch.session_secret = change-me +multitwitch.twitch_client_id = change-me +multitwitch.twitch_client_secret = change-me +multitwitch.twitch_redirect_uri = http://localhost:6543/auth/twitch/callback +multitwitch.twitch_scope = user:read:follows + +Start the app: + + source .venv/bin/activate + pserve development.ini + +The app automatically loads development.ini.local overrides. diff --git a/development.ini b/development.ini index acf2607..0d08a99 100644 --- a/development.ini +++ b/development.ini @@ -1,5 +1,10 @@ [app:main] use = egg:multitwitch +multitwitch.session_secret = change-me-in-development +multitwitch.twitch_client_id = change-me +multitwitch.twitch_client_secret = change-me +multitwitch.twitch_redirect_uri = http://localhost:6543/auth/twitch/callback +multitwitch.twitch_scope = user:read:follows pyramid.reload_templates = true pyramid.debug_authorization = false @@ -12,7 +17,7 @@ pyramid.includes = [server:main] use = egg:waitress#main -host = 0.0.0.0 +host = localhost port = 6543 # Begin logging configuration diff --git a/multitwitch/__init__.py b/multitwitch/__init__.py index 2ae0951..ea70e00 100644 --- a/multitwitch/__init__.py +++ b/multitwitch/__init__.py @@ -1,11 +1,40 @@ +import configparser +from pathlib import Path + from pyramid.config import Configurator +from pyramid.session import SignedCookieSessionFactory from .config import routes + +def _apply_local_ini_overrides(global_config, settings): + config_path = global_config.get('__file__') + if not config_path: + return + local_path = Path(config_path + '.local') + if not local_path.exists(): + return + + parser = configparser.RawConfigParser() + parser.read(str(local_path)) + if not parser.has_section('app:main'): + return + + for key, value in parser.items('app:main'): + settings[key] = value + def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - config = Configurator(settings=settings) + _apply_local_ini_overrides(global_config, settings) + session_secret = settings.get( + 'multitwitch.session_secret', + 'dev-only-session-secret-change-me', + ) + session_factory = SignedCookieSessionFactory(session_secret) + config = Configurator( + settings=settings, + session_factory=session_factory, + ) config.include(routes) return config.make_wsgi_app() - diff --git a/multitwitch/config.py b/multitwitch/config.py index 9905c2e..a75be1a 100644 --- a/multitwitch/config.py +++ b/multitwitch/config.py @@ -6,5 +6,20 @@ def routes(config): config.add_route('favicon', '/favicon.ico') config.add_view(WebView.favicon, route_name='favicon') - config.add_route('root', '*streams') - config.add_view(WebView.home, route_name='root') + config.add_route('home', '/') + config.add_view(WebView.home, route_name='home') + + config.add_route('auth_twitch_login', '/auth/twitch/login') + config.add_view(WebView.twitch_login, route_name='auth_twitch_login') + + config.add_route('auth_twitch_callback', '/auth/twitch/callback') + config.add_view(WebView.twitch_callback, route_name='auth_twitch_callback') + + config.add_route('auth_twitch_logout', '/auth/twitch/logout') + config.add_view(WebView.twitch_logout, route_name='auth_twitch_logout') + + config.add_route('api_followed_channels', '/api/followed-channels') + config.add_view(WebView.followed_channels, route_name='api_followed_channels') + + config.add_route('watch', '/*streams') + config.add_view(WebView.home, route_name='watch') diff --git a/multitwitch/lib/session.py b/multitwitch/lib/session.py index 239ab19..c02485f 100644 --- a/multitwitch/lib/session.py +++ b/multitwitch/lib/session.py @@ -22,7 +22,11 @@ def wrapper(request): body = tmpl.render(data) else: body = data - return Response(body, content_type=content_type) + return Response( + body=body.encode('utf-8'), + content_type=content_type, + charset='utf-8', + ) return staticmethod(wrapper) return decorator @@ -36,8 +40,9 @@ def decorator(f): def wrapper(request): retval = f(request) return Response( - body=json.dumps(retval), - content_type='application/json' - ) + body=json.dumps(retval).encode('utf-8'), + content_type='application/json', + charset='utf-8', + ) return staticmethod(wrapper) return decorator diff --git a/multitwitch/lib/twitch.py b/multitwitch/lib/twitch.py new file mode 100644 index 0000000..7e60870 --- /dev/null +++ b/multitwitch/lib/twitch.py @@ -0,0 +1,168 @@ +import json +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +AUTHORIZE_URL = 'https://id.twitch.tv/oauth2/authorize' +TOKEN_URL = 'https://id.twitch.tv/oauth2/token' +VALIDATE_URL = 'https://id.twitch.tv/oauth2/validate' +FOLLOWED_CHANNELS_URL = 'https://api.twitch.tv/helix/channels/followed' +FOLLOWED_STREAMS_URL = 'https://api.twitch.tv/helix/streams/followed' + + +class TwitchAPIError(Exception): + def __init__(self, message, status_code=None): + Exception.__init__(self, message) + self.status_code = status_code + + +class TwitchClient(object): + def __init__(self, settings): + self.client_id = settings.get('multitwitch.twitch_client_id', '').strip() + self.client_secret = settings.get('multitwitch.twitch_client_secret', '').strip() + self.redirect_uri = settings.get('multitwitch.twitch_redirect_uri', '').strip() + self.scope = settings.get( + 'multitwitch.twitch_scope', + 'user:read:follows', + ).strip() + + def is_configured(self): + return bool(self.client_id and self.client_secret and self.redirect_uri) + + def create_authorize_url(self, state): + query = urllib.parse.urlencode({ + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': self.scope, + 'state': state, + }) + return '%s?%s' % (AUTHORIZE_URL, query) + + def generate_state(self): + return uuid.uuid4().hex + + def exchange_code(self, code): + return self._post_form(TOKEN_URL, { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': self.redirect_uri, + }) + + def refresh_token(self, refresh_token): + return self._post_form(TOKEN_URL, { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + }) + + def validate_token(self, access_token): + request = urllib.request.Request( + VALIDATE_URL, + headers={ + 'Authorization': 'OAuth %s' % access_token, + }, + ) + return self._load_json(request) + + def get_followed_channels(self, access_token, user_id): + channels = [] + cursor = None + while True: + params = { + 'user_id': user_id, + 'first': '100', + } + if cursor: + params['after'] = cursor + request = urllib.request.Request( + '%s?%s' % ( + FOLLOWED_CHANNELS_URL, + urllib.parse.urlencode(params), + ), + headers=self._helix_headers(access_token), + ) + payload = self._load_json(request) + channels.extend(payload.get('data', [])) + cursor = payload.get('pagination', {}).get('cursor') + if not cursor: + break + return channels + + def get_followed_live_streams(self, access_token, user_id): + streams = [] + cursor = None + while True: + params = { + 'user_id': user_id, + 'first': '100', + } + if cursor: + params['after'] = cursor + request = urllib.request.Request( + '%s?%s' % ( + FOLLOWED_STREAMS_URL, + urllib.parse.urlencode(params), + ), + headers=self._helix_headers(access_token), + ) + payload = self._load_json(request) + streams.extend(payload.get('data', [])) + cursor = payload.get('pagination', {}).get('cursor') + if not cursor: + break + return streams + + def build_auth_record(self, token_payload, validation_payload): + expires_at = int(time.time()) + int(token_payload.get('expires_in', 0)) + scopes = token_payload.get('scope') or validation_payload.get('scopes') or [] + return { + 'access_token': token_payload.get('access_token'), + 'refresh_token': token_payload.get('refresh_token'), + 'expires_at': expires_at, + 'scope': scopes, + } + + def build_user_record(self, validation_payload): + return { + 'id': validation_payload.get('user_id'), + 'login': validation_payload.get('login'), + 'name': validation_payload.get('login'), + 'scopes': validation_payload.get('scopes', []), + } + + def _helix_headers(self, access_token): + return { + 'Authorization': 'Bearer %s' % access_token, + 'Client-Id': self.client_id, + } + + def _post_form(self, url, payload): + body = urllib.parse.urlencode(payload).encode('utf-8') + request = urllib.request.Request(url, data=body) + return self._load_json(request) + + def _load_json(self, request): + try: + response = urllib.request.urlopen(request) + try: + return json.loads(response.read().decode('utf-8')) + finally: + response.close() + except urllib.error.HTTPError as error: + status_code = getattr(error, 'code', None) + raw_body = error.read().decode('utf-8') + try: + payload = json.loads(raw_body) + message = payload.get('message') or payload.get('error') or raw_body + except ValueError: + message = raw_body or str(error) + raise TwitchAPIError(message, status_code=status_code) + except urllib.error.URLError as error: + raise TwitchAPIError(str(error)) diff --git a/multitwitch/static/css/multitwitch.css b/multitwitch/static/css/multitwitch.css index ba49e50..1d9e22f 100644 --- a/multitwitch/static/css/multitwitch.css +++ b/multitwitch/static/css/multitwitch.css @@ -16,8 +16,11 @@ a:hover { } #streams { - text-align: center; + align-content: flex-start; + display: flex; float: left; + flex-wrap: wrap; + justify-content: center; margin: 0; margin-right: -100px; padding: 0; @@ -27,6 +30,35 @@ iframe { border:0 none; } +.stream_tile { + display: block; + flex: 0 0 auto; + margin: 2px; + overflow: hidden; + position: relative; +} + +.stream_close { + background: rgba(0,0,0,0.72); + border: 1px solid rgba(255,255,255,0.7); + border-radius: 999px; + color: white; + cursor: pointer; + font-size: 13px; + height: 24px; + line-height: 20px; + padding: 0; + position: absolute; + right: 8px; + top: 8px; + width: 24px; + z-index: 2; +} + +.stream_close:hover { + background: rgba(180,68,68,0.9); +} + #chatbox { float: right; width: 320px; @@ -81,31 +113,218 @@ iframe { bottom: 12px; } +#flash_messages { + left: 8px; + position: absolute; + top: 8px; + width: 360px; + z-index: 10; +} + +.flash_message { + background: rgba(0,0,0,0.85); + border: 1px solid #666; + margin-bottom: 6px; + padding: 8px 10px; +} + +.flash_message.error { + border-color: #b44444; +} + +.flash_message.success { + border-color: #4b8e0b; +} + .optionbox { z-index: 5; display: none; - background: rgba(0,0,0,0.65); - padding: 5px; + background: rgba(8,10,14,0.82); + box-shadow: 0 18px 60px rgba(0,0,0,0.45); + padding: 18px; margin: 0 auto; - border: 2px solid white; - border-radius: 10px; + border: 1px solid rgba(255,255,255,0.18); + border-radius: 16px; -moz-border-radius: 10px; -webkit-border-radius: 10px; position: absolute; + backdrop-filter: blur(8px); } #change_streams { - width: 127px; + height: 70vh; + max-height: 80vh; + min-height: 70vh; + overflow-y: auto; + width: 520px; +} + +.optionbox_title { + color: #f4f1ea; + font-size: 16px; + font-weight: bold; + letter-spacing: 0.04em; + margin-bottom: 10px; + text-transform: uppercase; +} + +.change_streams_section { + margin-bottom: 18px; + padding: 14px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; +} + +.followed_section { + margin-bottom: 14px; +} + +#followed_channels_status { + color: #d5d7dc; + margin-bottom: 10px; +} + +.followed_channels_filter { + background: rgba(0,0,0,0.28); + border: 1px solid rgba(255,255,255,0.16); + border-radius: 10px; + box-sizing: border-box; + color: white; + margin-bottom: 10px; + padding: 10px 12px; + width: 100%; +} + +.followed_channels_filter:focus, +.streamlist_item .stream_name:focus { + border-color: rgba(231,97,24,0.8); + box-shadow: 0 0 0 2px rgba(231,97,24,0.18); + outline: none; +} + +#followed_channels_list { + max-height: 220px; + overflow-y: auto; +} + +.change_streams_actions { + margin-top: 14px; +} + +.followed_channel { + border-top: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.02); + border-left: 0 none; + border-right: 0 none; + border-bottom: 0 none; + color: white; + cursor: pointer; + display: block; + padding: 10px 12px; + border-radius: 10px; + margin-bottom: 6px; + text-align: left; + width: 100%; +} + +.followed_channel:first-child { + border-top: 1px solid rgba(255,255,255,0.15); +} + +.followed_channel:hover { + background: rgba(255,255,255,0.10); +} + +.followed_channel_name_row { + align-items: center; + display: flex; + justify-content: space-between; +} + +.followed_channel_link { + color: #f4f1ea; + display: inline-block; + font-weight: bold; +} + +.followed_channel_badge { + border-radius: 999px; + font-size: 10px; + letter-spacing: 0.08em; + padding: 2px 7px; +} + +.followed_channel_badge.live { + background: #b44444; +} + +.followed_channel_badge.offline { + background: #555; +} + +.followed_channel_meta { + color: #ccc; + font-size: 11px; + margin-top: 5px; +} + +.followed_channel.empty { + color: #ccc; +} + +.streamlist_item { + align-items: center; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 10px; + display: flex; + gap: 10px; + margin-bottom: 8px; + padding: 10px 12px; } .streamlist_item .stream_name { - width: 123px; + background: transparent; + border: 0 none; + color: white; + width: 100%; } .fullwidth { width: 100%; } +.add_stream_row { + margin-top: 10px; +} + +.action_button { + border: 0 none; + border-radius: 999px; + cursor: pointer; + font-weight: bold; + padding: 10px 16px; +} + +.primary_button { + background: #e76118; + color: white; +} + +.primary_button:hover { + background: #ff7b2f; +} + +.secondary_button { + background: rgba(255,255,255,0.10); + color: white; +} + +.secondary_button:hover { + background: rgba(255,255,255,0.18); +} + .left { float: left; } diff --git a/multitwitch/static/js/multitwitch.js b/multitwitch/static/js/multitwitch.js index 4f8bed5..584d8d2 100644 --- a/multitwitch/static/js/multitwitch.js +++ b/multitwitch/static/js/multitwitch.js @@ -2,6 +2,17 @@ var chat_hidden = false; var num_streams = -1; var streams = new Array(); var chat_tabs; +var followedChannelsLoaded = false; +var followedChannelsLoading = false; +var followedChannelsData = new Array(); + +function twitch_parent_query() { + var parts = new Array(); + for (var i = 0; i < twitchParents.length; i++) { + parts.push('parent=' + encodeURIComponent(twitchParents[i])); + } + return parts.join('&'); +} function optimize_size(n) { // Call with n = -1 to use previously known quantity @@ -61,8 +72,10 @@ function optimize_size(n) { wrapper_padding = (height - num_rows * max_height)/2; } } - $(".stream").height(Math.floor(best_height)); - $(".stream").width(Math.floor(best_width)); + $(".stream_tile").height(Math.floor(best_height)); + $(".stream_tile").width(Math.floor(best_width)); + $(".stream").height('100%'); + $(".stream").width('100%'); $("#streams").css("padding-top", wrapper_padding); } @@ -105,14 +118,15 @@ function toggle_chat() { } function change_streams() { - absolute_center($("#change_streams")); + position_change_streams(); $("#change_streams").show(); + load_followed_channels_once(); focus_last_stream_box(); } function add_stream_item() { $("#streamlist").append($(item_string)); - absolute_center($("#change_streams")); + position_change_streams(); focus_last_stream_box(); } @@ -125,11 +139,11 @@ function stream_item_keyup(e) { } function stream_object(name) { - return $(''); + return $('
'); } function chat_object(name) { - return $('
'); + return $('
'); } function chat_tab_object(name) { @@ -154,12 +168,92 @@ function focus_last_stream_box() { } } +function position_change_streams() { + var modal = $("#change_streams"); + var modalWidth = modal.outerWidth(); + modal.css('position', 'fixed'); + modal.css('top', '15vh'); + modal.css('left', '50%'); + modal.css('margin-left', -(modalWidth / 2) + 'px'); +} + +function update_url() { + var new_url = ""; + for (var i = 0; i < streams.length; i++) { + new_url = new_url + '/' + streams[i]; + } + if (new_url == "") { + new_url = "/"; + } + history.replaceState(null, "", new_url); +} + +function sync_chat_visibility() { + if (streams.length === 0) { + hide_chat(); + $("#helpbox").show(); + return; + } + $("#helpbox").hide(); + if (chat_hidden) { + show_chat(); + } +} + +function add_stream(stream_name) { + for (var i = 0; i < streams.length; i++) { + if (streams[i] == stream_name) { + return; + } + } + streams.push(stream_name); + $("#streams").append(stream_object(stream_name)); + $("#chatbox").append(chat_object(stream_name)); + $("#tablist").append(chat_tab_object(stream_name)); + chat_tabs.tabs("refresh"); + sync_chat_visibility(); + optimize_size(streams.length); + update_stream_list(); + update_url(); +} + +function close_stream_by_button(button) { + var tile = $(button).closest(".stream_tile"); + var tiles = $("#streams .stream_tile"); + var index = tiles.index(tile); + if (index === -1) { + return false; + } + remove_stream_at_index(index); + return false; +} + +function remove_stream_at_index(index) { + if (index < 0 || index >= streams.length) { + return; + } + var stream_name = streams[index]; + streams.splice(index, 1); + $("#streams .stream_tile").eq(index).remove(); + + if ($.inArray(stream_name, streams) === -1) { + $("#chat-" + stream_name).remove(); + $('#tablist a[href="#chat-' + stream_name + '"]').parent().remove(); + chat_tabs.tabs("refresh"); + } + + sync_chat_visibility(); + optimize_size(streams.length); + update_stream_list(); + update_url(); +} + function close_change_streams(apply) { var new_streams; if(apply) { // Remove all the streams that got unchecked new_streams = new Array(); - var stream_elements = $("#streams .stream"); + var stream_elements = $("#streams .stream_tile"); var chat_elements = $("#chatbox .stream_chat"); var chat_tab_elements = $("#tablist li"); var list_checks = $("#streamlist .check"); @@ -179,6 +273,9 @@ function close_change_streams(apply) { if (stream_name == "") { continue; } + if ($.inArray(stream_name, new_streams) != -1) { + continue; + } new_streams.push(stream_name); $("#streams").append(stream_object(stream_name)); $("#chatbox").append(chat_object(stream_name)); @@ -186,13 +283,145 @@ function close_change_streams(apply) { chat_tabs.tabs("refresh"); } streams = new_streams; + sync_chat_visibility(); optimize_size(streams.length); - var new_url = ""; - for (var i = 0; i < streams.length; i++) { - new_url = new_url + '/' + streams[i]; - } - history.replaceState(null, "", new_url); + update_url(); } $("#change_streams").hide(); update_stream_list(); } + +function load_followed_channels_once() { + if (!followedChannelsLoaded && !followedChannelsLoading) { + load_followed_channels(); + } +} + +function load_followed_channels() { + followedChannelsLoading = true; + $("#followed_channels_status").text("Loading followed channels..."); + $("#followed_channels_list").empty(); + clear_followed_channels_filter(); + $.getJSON('/api/followed-channels', function(data) { + followedChannelsLoaded = true; + followedChannelsLoading = false; + if (!data.configured) { + $("#followed_channels_status").text("Twitch integration is not configured on the server."); + return; + } + if (!data.connected) { + $("#followed_channels_status").text("Connect your Twitch account to load the channels you follow."); + return; + } + if (data.error) { + $("#followed_channels_status").text(data.error); + return; + } + $("#followed_channels_status").text("Click a channel to add it to the layout."); + followedChannelsData = data.channels || []; + render_followed_channels(followedChannelsData); + }).fail(function(xhr) { + followedChannelsLoading = false; + $("#followed_channels_status").text(parse_failed_followed_channels_response(xhr)); + }); +} + +function render_followed_channels(channels) { + $("#followed_channels_list").empty(); + if (!channels || channels.length === 0) { + $("#followed_channels_list").append($('
No followed channels found.
')); + return; + } + for (var i = 0; i < channels.length; i++) { + $("#followed_channels_list").append(followed_channel_object(channels[i])); + } +} + +function clear_followed_channels_filter() { + var filterInput = $("#followed_channels_filter"); + if (filterInput.length > 0) { + filterInput.val(''); + } +} + +function filter_followed_channels() { + var filterInput = $("#followed_channels_filter"); + if (filterInput.length === 0) { + return; + } + var query = $.trim(filterInput.val().toLowerCase()); + if (query === "") { + render_followed_channels(followedChannelsData); + return; + } + var filtered = new Array(); + for (var i = 0; i < followedChannelsData.length; i++) { + var channel = followedChannelsData[i]; + var haystack = [ + channel.name || '', + channel.login || '', + channel.game_name || '', + channel.title || '' + ].join(' ').toLowerCase(); + if (haystack.indexOf(query) !== -1) { + filtered.push(channel); + } + } + render_followed_channels(filtered); +} + +function parse_failed_followed_channels_response(xhr) { + if (xhr && xhr.responseJSON && xhr.responseJSON.error) { + return xhr.responseJSON.error; + } + if (xhr && xhr.responseText) { + try { + var data = JSON.parse(xhr.responseText); + if (data.error) { + return data.error; + } + } catch (error) { + } + return xhr.responseText; + } + return "Could not load followed channels."; +} + +function followed_channel_object(channel) { + var row = $(''); + var nameRow = $('
'); + var name = $(''); + var status = $(''); + + name.text(channel.name); + status.text(channel.is_live ? 'LIVE' : 'OFFLINE'); + if (channel.is_live) { + status.addClass('live'); + } else { + status.addClass('offline'); + } + + row.attr('data-login', channel.login); + row.click(function() { + add_stream(channel.login); + update_stream_list(); + return false; + }); + nameRow.append(name); + nameRow.append(status); + row.append(nameRow); + if (channel.game_name || channel.title) { + var metaText = ''; + if (channel.game_name) { + metaText += channel.game_name; + } + if (channel.title) { + if (metaText !== '') { + metaText += ' - '; + } + metaText += channel.title; + } + row.append($('
').text(metaText)); + } + return row; +} diff --git a/multitwitch/templates/web/home.tmpl b/multitwitch/templates/web/home.tmpl index 4007345..14238be 100644 --- a/multitwitch/templates/web/home.tmpl +++ b/multitwitch/templates/web/home.tmpl @@ -21,6 +21,7 @@ })();