Compare commits

..

10 commits

9 changed files with 293 additions and 237 deletions

1
.gitignore vendored
View file

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

View file

@ -3,6 +3,34 @@ Least-favourite twitch streamers here
## Change Log ## 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 ### 1.7
- Fixed bug with displaying special symbols in stream name - Fixed bug with displaying special symbols in stream name
### 1.6 ### 1.6

10
main.py
View file

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

View file

@ -2,21 +2,54 @@ import cherrypy
import requests import requests
import threading import threading
import time import time
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
from urllib.parse import quote from urllib.parse import quote
class TwitchClient: class TwitchClient:
def __init__(self, token, oauth, freq=2): def __init__(self, token, secret, freq=2):
self.token = token self.token = token
self.oauth = oauth
self.lock = threading.Lock() 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.urlbase_v6 = 'https://api.twitch.tv/helix'
self.last_q = time.time() self.last_q = time.time()
self.delay = 1 / freq 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): def do_q(self, base, header):
""" """
Do query for twitch server Do query for twitch server
@ -31,8 +64,13 @@ class TwitchClient:
if delta < self.delay: if delta < self.delay:
time.sleep(delta) # Sleep remaining time time.sleep(delta) # Sleep remaining time
r = requests.get(base, headers=header).json() r = requests.get(base, headers=header).json()
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() self.last_q = time.time()
cherrypy.log('Request: OK')
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
cherrypy.log('Request: FAIL') cherrypy.log('Request: FAIL')
cherrypy.log('Error: {}'.format(e)) cherrypy.log('Error: {}'.format(e))
@ -62,7 +100,7 @@ class TwitchClient:
:return: string with get query result or None :return: string with get query result or None
""" """
header, base = self.get_base('v6') 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): def get_game_id_v6(self, name):
""" """
@ -72,7 +110,7 @@ class TwitchClient:
""" """
header, base = self.get_base('v6') 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'): if r and r.get('data'):
return r['data'][0]['id'], r['data'][0]['name'] return r['data'][0]['id'], r['data'][0]['name']
@ -92,11 +130,11 @@ class TwitchClient:
header, base = self.get_base('v6') header, base = self.get_base('v6')
init_q_template = "{}/streams?language={}&first={}&game_id={}" init_q_template = "{}/streams?language={}&first={}&game_id={}"
q_template = "{}/streams?language={}&first={}&after={}&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']) result['streams'].extend(data['data'])
while len(data.get('data', [])) > 0: # there must be non zero value, but search is kinda broken now while len(data.get('data', [])) > 0: # there must be non zero value, but search is kinda broken now
result['streams'].extend(data['data']) 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) return self.unique_streams_v6(result)
def get_irl_live_streams_v6(self, lang): def get_irl_live_streams_v6(self, lang):
@ -105,17 +143,38 @@ class TwitchClient:
q_template = "{}/streams?language={}&first={}&after={}{}" q_template = "{}/streams?language={}&first={}&after={}{}"
game_id = '' 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: for irl_id in irl_ids:
game_id += '&game_id={}'.format(irl_id) game_id += '&game_id={}'.format(irl_id)
result = {'_total': 0, 'streams': []} result = {'_total': 0, 'streams': []}
data = self.do_q(init_q_template.format(base, lang, 100, game_id), header) data = self.do_q_auth_v6(init_q_template.format(base, lang, 100, game_id), header)
result['streams'].extend(data['data'])
while len(data.get('data', [])) > 0: # there must be non zero value, but search is kinda broken now while len(data.get('data', [])) > 0: # there must be non zero value, but search is kinda broken now
result['streams'].extend(data['data']) 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) return self.unique_streams_v6(result)
def unique_streams_v6(self, result): def unique_streams_v6(self, result):

View file

@ -3,64 +3,101 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>FLeast</title> <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"> <link rel="shortcut icon" href="./favicon.ico" type="image/png">
</head> </head>
<body> <style>
<section id="header"> .container-sm {{
<strong> max-width: 1450px;
<h1>FLeast - your least favourite streamers here</h1> }}
</strong>
</section>
<section id="pageContent">
<article>
<h2>Usage:</h2>
<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...
</p>
<form method="get" action="./">
<table class="Input">
<tbody>
<tr>
<td>
<label>Game </label>
</td>
<td>
<label>Language </label>
</td>
</tr>
<tr>
<td>
<input name="game" type="text" maxlength="255" value=""/>
</td>
<td>
<select class="element select medium" name="lang">
{_opt_langs_}
</select>
</td>
<td>
<input type="submit" value="Search" />
</td>
</tr>
</tbody>
</table>
</form>
</article>
</section> body {{
<footer> 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>
<div class="container-sm text-white">
<br>
<h1>Fleast — find new favourite small streamer</h1>
<hr>
<p> <p>
Developed by Alex Vanin | <a href="https://twitter.com/AlexVanin" target="_blank">twitter</a> | Search streams by specifying category from twitch.tv and the
<a href="https://github.com/AlexVanin/fleast" target="_blank">github</a><br> language of the stream.<br>Streams will be sorted by <b>ascending</b> order.
Version: {_version_}
</p> </p>
</footer> <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>
<tr>
<td>
Category
</td>
<td>
Language
</td>
</tr>
<tr>
<td>
<input name="game" type="text" class="form-control-sm" maxlength="255" value="" placeholder="Specify category"/>
</td>
<td>
<select name="lang" class="form-control-sm">
{_opt_langs_}
</select>
</td>
<td>
<input type="submit" value="Search" class="btn btn-primary btn-sm"/>
</td>
</tr>
</table>
</form>
<hr>
<footer>
<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> </body>
</html> </html>

View file

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

View file

@ -3,66 +3,99 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>FLeast</title> <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"> <link rel="shortcut icon" href="./favicon.ico" type="image/png">
</head> </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> <body>
<section id="header"> <div class="container-sm text-white">
<strong><h1>FLeast - your least favourite streamers here</h1></strong> <br>
</section> <h1>Fleast — find new favourite small streamer</h1>
<section id="pageContent"> <hr>
<article> <p>
<h2>Usage:</h2> The category must be specified by <b>exact name</b>.<br>Search
<p> <b>IRL</b> to find streams from <i>Just Chatting</i>,
Write the name of the game and select the language.<br> <i>Travel & Outdoors</i>, and other categories.
The name must _exactly_ match the name of the game on twitch.tv </p>
i.e. csgo must be "Counter-Strike: Global Offensive".<br><br> <form method="get" action="./">
You can type <b>IRL</b> in game field and it will output streams from <table>
<i>Just Chatting</i>, <i>Travel & Outdoors</i>, etc... <tr>
</p> <td>
<form method="get" action="./"> Category
<table class="Input"> </td>
<tbody> <td>
<tr> Language
<td> </td>
<label>Game </label> </tr>
</td> <tr>
<td> <td>
<label>Language </label> <input name="game" type="text" class="form-control-sm" maxlength="255" value="{_game_name_}" placeholder="Specify category"/>
</td> </td>
</tr> <td>
<tr> <select name="lang" class="form-control-sm">
<td>
<input name="game" type="text" maxlength="255" value=""/>
</td>
<td>
<select class="element select medium" name="lang">
{_opt_langs_} {_opt_langs_}
</select> </select>
</td> </td>
<td> <td>
<input type="submit" value="Search" /> <input type="submit" value="Search" class="btn btn-primary btn-sm"/>
</td> </td>
</tr> </tr>
</tbody>
</table> </table>
</form> </form>
</article> <hr>
<article> <h4> Found {_stream_num_} streams:</h4>
<h2>Found {_stream_num_} streams: </h2><br> <div class="grid">
<div class="container">
{_stream_list_} {_stream_list_}
</div>
<hr>
<footer>
<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> </div>
</article> </footer>
</section> </div>
<footer>
<p>
Developed by Alex 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>
</footer>
</body> </body>
</html> </html>

View file

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