Compare commits

...

10 commits

9 changed files with 293 additions and 237 deletions

1
.gitignore vendored
View file

@ -1,7 +1,6 @@
__pycache__
*.swp
.token
.oauth
.secret
server.conf
.*.log

View file

@ -3,6 +3,34 @@ Least-favourite twitch streamers here
## Change Log
### 1.10.0
#### Changed
- Refresh website design
### 1.9.1
#### Fixed
- Server does not fail when processing API response withour `cursor` field.
- String formatting for older python3 versions.
### 1.9
#### Fixed
- Char escaping and various typos.
- Unique filter for streams.
#### Added
- Runtime ouath token generation.
#### Changed
- Use threshold on amount of returned streams to resend requests.
#### Removed
- v5 (kraken) API support.
- Daemonizer component.
### 1.8
#### Fixed
- Search queries for v5 and v6 API
### 1.7
- Fixed bug with displaying special symbols in stream name
### 1.6

10
main.py
View file

@ -4,7 +4,7 @@
import cherrypy
from twitch import TwitchClient
ver = '1.9.0-pre-3'
ver = '1.10.0'
class FleastServer(object):
@ -12,8 +12,8 @@ class FleastServer(object):
try:
with open('.token', 'r') as reader:
self.twitch_token = reader.read().strip()
with open('.oauth', 'r') as reader:
self.oauth_token = reader.read().strip()
with open('.secret', 'r') as reader:
self.secret = reader.read().strip()
with open('./web/fl.html', 'r') as reader:
self.index_page = reader.read()
with open('./web/fl_template_main.html', 'r') as reader:
@ -22,7 +22,7 @@ class FleastServer(object):
self.templ_stream = reader.read()
with open('./web/fl_template_lang.html', 'r') as reader:
self.templ_lang = reader.read().splitlines()
self.client = TwitchClient(self.twitch_token, self.oauth_token, freq=1)
self.client = TwitchClient(self.twitch_token, self.secret, freq=1)
except:
print("Cannot read token for twitch app or templates, abort.")
exit(1)
@ -90,7 +90,7 @@ class FleastServer(object):
s['thumbnail_url'].format(width=320, height=180),
self.to_html(s['title']),
s['user_name'],
s['viewer_count']) + '\n'
s['viewer_count'])
return self.templ_main.format(_stream_num_=len(data['streams']),
_game_name_=game,

View file

@ -2,21 +2,54 @@ import cherrypy
import requests
import threading
import time
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
from urllib.parse import quote
class TwitchClient:
def __init__(self, token, oauth, freq=2):
def __init__(self, token, secret, freq=2):
self.token = token
self.oauth = oauth
self.lock = threading.Lock()
self.header_v6 = {'Client-ID': self.token, 'Authorization': 'Bearer ' + self.oauth}
self.header_v6 = {'Client-ID': self.token}
self.urlbase_v6 = 'https://api.twitch.tv/helix'
self.last_q = time.time()
self.delay = 1 / freq
# authentication
self.oauth = ''
self.oauth_token_url = 'https://id.twitch.tv/oauth2/token'
self.auth_secret = secret
oauth_clint = BackendApplicationClient(client_id=self.token)
self.oauth_session = OAuth2Session(client=oauth_clint)
self.update_oauth()
def update_oauth(self):
"""
Update self.oauth token based on client id and secret.
:return: nothing
"""
token = self.oauth_session.fetch_token(token_url=self.oauth_token_url,
client_secret=self.auth_secret,
include_client_id=True)
self.oauth = token['access_token']
def do_q_auth_v6(self, base, header):
"""
Do query with v6 authentication header and single retry.
:param base: string with requesting URL
:param header: dictionary of http headers
:return: string with response or None
"""
result = self.do_q(base, header | {'Authorization': 'Bearer ' + self.oauth})
if result is not None:
return result
self.update_oauth()
return self.do_q(base, header | {'Authorization': 'Bearer ' + self.oauth})
def do_q(self, base, header):
"""
Do query for twitch server
@ -31,8 +64,13 @@ class TwitchClient:
if delta < self.delay:
time.sleep(delta) # Sleep remaining time
r = requests.get(base, headers=header).json()
self.last_q = time.time()
error_message = r.get("error", "")
if len(error_message) > 0:
cherrypy.log('Request: fail with error "%s"' % error_message)
r = None
else:
cherrypy.log('Request: OK')
self.last_q = time.time()
except requests.exceptions.RequestException as e:
cherrypy.log('Request: FAIL')
cherrypy.log('Error: {}'.format(e))
@ -62,7 +100,7 @@ class TwitchClient:
:return: string with get query result or None
"""
header, base = self.get_base('v6')
return self.do_q(base + q, header)
return self.do_q_auth_v6(base + q, header)
def get_game_id_v6(self, name):
"""
@ -72,7 +110,7 @@ class TwitchClient:
"""
header, base = self.get_base('v6')
r = self.do_q('{}/games?name={}'.format(base, name), header)
r = self.do_q_auth_v6('{}/games?name={}'.format(base, name), header)
if r and r.get('data'):
return r['data'][0]['id'], r['data'][0]['name']
@ -92,11 +130,11 @@ class TwitchClient:
header, base = self.get_base('v6')
init_q_template = "{}/streams?language={}&first={}&game_id={}"
q_template = "{}/streams?language={}&first={}&after={}&game_id={}"
data = self.do_q(init_q_template.format(base, lang, 100, game_id[0]), header)
data = self.do_q_auth_v6(init_q_template.format(base, lang, 100, game_id[0]), header)
result['streams'].extend(data['data'])
while len(data.get('data', [])) > 0: # there must be non zero value, but search is kinda broken now
result['streams'].extend(data['data'])
data = self.do_q(q_template.format(base, lang, 100, data['pagination']['cursor'], game_id[0]), header)
data = self.do_q_auth_v6(q_template.format(base, lang, 100, data['pagination']['cursor'], game_id[0]), header)
return self.unique_streams_v6(result)
def get_irl_live_streams_v6(self, lang):
@ -105,17 +143,38 @@ class TwitchClient:
q_template = "{}/streams?language={}&first={}&after={}{}"
game_id = ''
irl_ids = ["509660", "509673", "509667", "509669", "509670", "509658",
"509672", "509671", "509664", "509663", "417752", "509659"]
"""
417752: Talk Shows & Podcasts
509658: Just Chatting
509659: ASMR
509660: Art
509663: Special Events
509664: Tabletop RPGs
509667: Food & Drink
509669: Beauty & Body Art
509670: Science & Technology
509671: Fitness & Health [exclude]
509672: Travel & Outdoors
509673: Makers & Crafting
515214: Politics [exclude]
518203: Sports
116747788: Pools, Hot Tubs, and Beaches
272263131: Animals, Aquariums, and Zoos [exclude]
1469308723: Software and Game Development
"""
irl_ids = ["417752", "509658", "509659", "509660", "509663", "509664",
"509667", "509669", "509670", "509672", "509673", "518203",
"116747788", "1469308723"]
for irl_id in irl_ids:
game_id += '&game_id={}'.format(irl_id)
result = {'_total': 0, 'streams': []}
data = self.do_q(init_q_template.format(base, lang, 100, game_id), header)
result['streams'].extend(data['data'])
data = self.do_q_auth_v6(init_q_template.format(base, lang, 100, game_id), header)
while len(data.get('data', [])) > 0: # there must be non zero value, but search is kinda broken now
result['streams'].extend(data['data'])
data = self.do_q(q_template.format(base, lang, 100, data['pagination']['cursor'], game_id), header)
if data['pagination'].get("cursor", None) is None: # sometimes server return results without cursor
break
data = self.do_q_auth_v6(q_template.format(base, lang, 100, data['pagination']['cursor'], game_id), header)
return self.unique_streams_v6(result)
def unique_streams_v6(self, result):

View file

@ -3,64 +3,101 @@
<head>
<meta charset="utf-8">
<title>FLeast</title>
<link rel="stylesheet" href="./style.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="shortcut icon" href="./favicon.ico" type="image/png">
</head>
<style>
.container-sm {{
max-width: 1450px;
}}
body {{
background-color: #333333;
}}
footer {{
background-color: #AEC6CF;
}}
.strname {{
font-weight: bold;
word-break: break-word;
}}
.struser {{
font-size: 15px;
font-weight: bold;
color: #0f0
}}
.strviews {{
font-size: 15px;
font-weight: bold;
color: #f00
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 340px));
justify-content: space-around;
row-gap: 30px;
}}
</style>
<body>
<section id="header">
<strong>
<h1>FLeast - your least favourite streamers here</h1>
</strong>
</section>
<section id="pageContent">
<article>
<h2>Usage:</h2>
<div class="container-sm text-white">
<br>
<h1>Fleast — find new favourite small streamer</h1>
<hr>
<p>
Write the name of the game and select the language.<br>
The name must _exactly_ match the name of the game on twitch.tv
i.e. csgo must be "Counter-Strike: Global Offensive".<br><br>
<b>IRL is back!</b> Sort of.<br>
You can type <b>IRL</b> in game field and it will output streams from
<i>Just Chatting</i>, <i>Travel & Outdoors</i>, etc...
Search streams by specifying category from twitch.tv and the
language of the stream.<br>Streams will be sorted by <b>ascending</b> order.
</p>
<p>
The category must be specified by <b>exact name</b>.<br>For example,
if you are looking for <i>Counter-Strike</i>, then you should write
<i>Counter-Strike: Global Offensive</i>.
</p>
<p>
You can use <b>IRL</b> like in good old days.<br>The output will
combine <i>Just Chatting</i>, <i>Travel & Outdoors</i>, and other categories.
</p>
<form method="get" action="./">
<table class="Input">
<tbody>
<table>
<tr>
<td>
<label>Game </label>
Category
</td>
<td>
<label>Language </label>
Language
</td>
</tr>
<tr>
<td>
<input name="game" type="text" maxlength="255" value=""/>
<input name="game" type="text" class="form-control-sm" maxlength="255" value="" placeholder="Specify category"/>
</td>
<td>
<select class="element select medium" name="lang">
<select name="lang" class="form-control-sm">
{_opt_langs_}
</select>
</td>
<td>
<input type="submit" value="Search" />
<input type="submit" value="Search" class="btn btn-primary btn-sm"/>
</td>
</tr>
</tbody>
</table>
</form>
</article>
</section>
<hr>
<footer>
<p>
Developed by Alex Vanin | <a href="https://twitter.com/AlexVanin" target="_blank">twitter</a> |
<div class="text-end text-black p-2">
Developed by Alexey Vanin | <a href="https://twitter.com/AlexVanin" target="_blank">twitter</a> |
<a href="https://github.com/AlexVanin/fleast" target="_blank">github</a><br>
Version: {_version_}
</p>
</div>
</footer>
</div>
</body>
</html>

View file

@ -1,13 +1,12 @@
<option value="ru" {}>russian</option>
<option value="en" {}>english</option>
<option value="de" {}>german</option>
<option value="zh-tw" {}>chinese (TW)</option>
<option value="fi" {}>finnish</option>
<option value="fr" {}>french</option>
<option value="it" {}>italian</option>
<option value="ja" {}>japanese</option>
<option value="ko" {}>korean</option>
<option value="no" {}>norwegian</option>
<option value="pl" {}>polish</option>
<option value="es" {}>spanish</option>
<option value="sv" {}>swedish</option>
<option value="ru" {}>🇷🇺Russian</option>
<option value="en" {}>🇬🇧English </option>
<option value="de" {}>🇩🇪German </option>
<option value="es" {}>🇪🇸Spanish</option>
<option value="fr" {}>🇫🇷French</option>
<option value="pl" {}>🇵🇱Polish</option>
<option value="ja" {}>🇯🇵Japanese</option>
<option value="zh-tw" {}>🇨🇳Chinese</option>
<option value="ko" {}>🇰🇷Korean</option>
<option value="fi" {}>🇫🇮Finnish</option>
<option value="no" {}>🇳🇴Norwegian</option>
<option value="sv" {}>🇸🇪Swedish</option>

View file

@ -3,66 +3,99 @@
<head>
<meta charset="utf-8">
<title>FLeast</title>
<link rel="stylesheet" href="./style.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="shortcut icon" href="./favicon.ico" type="image/png">
</head>
<style>
.container-sm {{
max-width: 1450px;
}}
body {{
background-color: #333333;
}}
footer {{
background-color: #AEC6CF;
}}
.strname {{
font-weight: bold;
word-break: break-word;
}}
.struser {{
font-size: 15px;
font-weight: bold;
color: #0f0
}}
.strviews {{
font-size: 15px;
font-weight: bold;
color: #f00
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 340px));
justify-content: space-around;
row-gap: 30px;
}}
</style>
<body>
<section id="header">
<strong><h1>FLeast - your least favourite streamers here</h1></strong>
</section>
<section id="pageContent">
<article>
<h2>Usage:</h2>
<div class="container-sm text-white">
<br>
<h1>Fleast — find new favourite small streamer</h1>
<hr>
<p>
Write the name of the game and select the language.<br>
The name must _exactly_ match the name of the game on twitch.tv
i.e. csgo must be "Counter-Strike: Global Offensive".<br><br>
You can type <b>IRL</b> in game field and it will output streams from
<i>Just Chatting</i>, <i>Travel & Outdoors</i>, etc...
The category must be specified by <b>exact name</b>.<br>Search
<b>IRL</b> to find streams from <i>Just Chatting</i>,
<i>Travel & Outdoors</i>, and other categories.
</p>
<form method="get" action="./">
<table class="Input">
<tbody>
<table>
<tr>
<td>
<label>Game </label>
Category
</td>
<td>
<label>Language </label>
Language
</td>
</tr>
<tr>
<td>
<input name="game" type="text" maxlength="255" value=""/>
<input name="game" type="text" class="form-control-sm" maxlength="255" value="{_game_name_}" placeholder="Specify category"/>
</td>
<td>
<select class="element select medium" name="lang">
<select name="lang" class="form-control-sm">
{_opt_langs_}
</select>
</td>
<td>
<input type="submit" value="Search" />
<input type="submit" value="Search" class="btn btn-primary btn-sm"/>
</td>
</tr>
</tbody>
</table>
</form>
</article>
<article>
<h2>Found {_stream_num_} streams: </h2><br>
<div class="container">
<hr>
<h4> Found {_stream_num_} streams:</h4>
<div class="grid">
{_stream_list_}
</div>
</article>
</section>
<hr>
<footer>
<p>
Developed by Alex Vanin | <a href="https://twitter.com/AlexVanin" target="_blank">twitter</a> |
<div class="text-end text-black p-2">
Developed by Alexey Vanin | <a href="https://twitter.com/AlexVanin" target="_blank">twitter</a> |
<a href="https://github.com/AlexVanin/fleast" target="_blank">github</a><br>
Version: {_version_}
</p>
</div>
</footer>
</div>
</body>
</html>

View file

@ -1,5 +1,5 @@
<div class="inner">
<a href="{0}" target="_blank"><img src={1}></a><br>
<div>
<a href="{0}" target="_blank"><img src={1}></a>
<div class="strname">{2}</div>
<span class="struser">{3}</span>
<span class="strviews"> :{4}</span>

View file

@ -1,99 +0,0 @@
body{background: #333333; line-height:1; font-family: arial;}
h1{font-size: 25px;}h2{font-size: 21px;}h3{font-size: 18px;}h4{font-size: 16px;}
table{border-collapse:collapse;border-spacing:0}
hr{display:block;height:1px;border:0;border-top:1px solid #ccc;margin:1em 0;padding:0}
#pageContent {
margin:0;padding:0;border:0;outline:0
max-width: 1000px;
margin: auto;
border: none;
}
#header {
padding:10px;
}
main {
float: left;
width: 60%;
}
aside {
float: right;
width: 30%;
}
article {
border-bottom: 2px dotted #999;
padding-bottom: 20px;
margin-bottom: 20px;
}
article h2 {
font-weight: normal;
margin-bottom: 12px;
}
article p {
}
main section {
}
footer {
background: #AEC6CF;
max-width: 1000px;
margin: auto;
clear: both;
text-align: right;
}
footer p {
padding: 20px;
}
aside > div {
margin: 10px auto;
background: #AEC6CF;
min-height: 100px;
}
body > section {
max-width: 1000px;
margin: auto;
padding: 30px 0px;
border-bottom: 1px solid #999;
color: #fff;
}
table.Input td, table.Input th {
border: 0px solid #AAAAAA;
padding: 2px 2px;
}
.container {
display: flex;
flex-wrap: wrap;
padding: 0px 0px 0px 10px
}
.inner {
overflow: auto;
word-wrap: break-word;
background-color: transparent;
width: 330px;
padding: 0px 0px 30px 0px
}
.strname {
font-weight: bold;
}
.struser {
font-size: 15px;
font-weight: bold;
color: #0f0
}
.strviews{
font-size: 15px;
font-weight: bold;
color: #f00
}