Skip to content

Commit d2f38ea

Browse files
authored
Merge pull request #146 from TotallyNotRobots/gonzobot+expand-yt-errors
Expand youtube.py error info
2 parents e703f8c + 459ccd3 commit d2f38ea

File tree

3 files changed

+163
-109
lines changed

3 files changed

+163
-109
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Change link_announcer.py to only warn on connection errors
1414
- Change user lookup logic in last.fm plugin
1515
- Refactor minecraft_ping plugin for updated mcstatus library
16+
- Expand youtube.py error information
1617
### Fixed
1718
- Fix matching exception in horoscope test
1819
- Fix youtube.py ISO time parse

plugins/youtube.py

Lines changed: 143 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,147 @@
11
import re
2+
from typing import Iterable, Mapping, Match, Optional, Union
23

34
import isodate
45
import requests
56

67
from cloudbot import hook
78
from cloudbot.bot import bot
89
from cloudbot.util import colors, timeformat
9-
from cloudbot.util.formatting import pluralize_auto
10+
from cloudbot.util.formatting import pluralize_suffix
11+
12+
youtube_re = re.compile(
13+
r'(?:youtube.*?(?:v=|/v/)|youtu\.be/|yooouuutuuube.*?id=)([-_a-zA-Z0-9]+)', re.I
14+
)
15+
ytpl_re = re.compile(
16+
r'(.*:)//(www.youtube.com/playlist|youtube.com/playlist)(:[0-9]+)?(.*)', re.I
17+
)
1018

11-
youtube_re = re.compile(r'(?:youtube.*?(?:v=|/v/)|youtu\.be/|yooouuutuuube.*?id=)([-_a-zA-Z0-9]+)', re.I)
1219

1320
base_url = 'https://www.googleapis.com/youtube/v3/'
14-
api_url = base_url + 'videos?part=contentDetails%2C+snippet%2C+statistics&id={}&key={}'
15-
search_api_url = base_url + 'search?part=id&maxResults=1'
16-
playlist_api_url = base_url + 'playlists?part=snippet%2CcontentDetails%2Cstatus'
17-
video_url = "http://youtu.be/%s"
18-
err_no_api = "The YouTube API is off in the Google Developers Console."
1921

2022

2123
class APIError(Exception):
22-
def __init__(self, message, response=None):
24+
def __init__(self, message: str, response: Optional[str] = None) -> None:
2325
super().__init__(message)
2426
self.message = message
2527
self.response = response
2628

2729

28-
def get_video_description(video_id):
29-
dev_key = bot.config.get_api_key("google_dev_key")
30-
request = requests.get(api_url.format(video_id, dev_key))
31-
json = request.json()
30+
class NoApiKeyError(APIError):
31+
def __init__(self) -> None:
32+
super().__init__("Missing API key")
33+
34+
35+
class NoResultsError(APIError):
36+
def __init__(self) -> None:
37+
super().__init__("No results")
38+
39+
40+
def raise_api_errors(response: requests.Response) -> None:
41+
try:
42+
response.raise_for_status()
43+
except requests.RequestException as e:
44+
try:
45+
data = response.json()
46+
except ValueError:
47+
raise e
48+
49+
errors = data.get('errors')
50+
if not errors:
51+
return
52+
53+
first_error = errors[0]
54+
domain = first_error['domain']
55+
reason = first_error['reason']
56+
raise APIError("API Error ({}/{})".format(domain, reason), data) from e
57+
58+
59+
def make_short_url(video_id: str) -> str:
60+
return "http://youtu.be/{}".format(video_id)
61+
62+
63+
ParamValues = Union[int, str]
64+
ParamMap = Mapping[str, ParamValues]
65+
Parts = Iterable[str]
66+
67+
68+
def do_request(
69+
method: str, parts: Parts, params: Optional[ParamMap] = None, **kwargs: ParamValues
70+
) -> requests.Response:
71+
api_key = bot.config.get_api_key("google_dev_key")
72+
if not api_key:
73+
raise NoApiKeyError()
74+
75+
if params:
76+
kwargs.update(params)
77+
78+
kwargs['part'] = ','.join(parts)
79+
kwargs['key'] = api_key
80+
return requests.get(base_url + method, kwargs)
81+
82+
83+
def get_video(video_id: str, parts: Parts) -> requests.Response:
84+
return do_request('videos', parts, params={'maxResults': 1, 'id': video_id})
85+
86+
87+
def get_playlist(playlist_id: str, parts: Parts) -> requests.Response:
88+
return do_request('playlists', parts, params={'maxResults': 1, 'id': playlist_id})
89+
3290

33-
if json.get('error'):
34-
if json['error']['code'] == 403:
35-
raise APIError(err_no_api, json)
91+
def do_search(term: str, result_type: str = 'video') -> requests.Response:
92+
return do_request(
93+
'search', ['snippet'], params={'maxResults': 1, 'q': term, 'type': result_type}
94+
)
3695

37-
raise APIError("Unknown error", json)
96+
97+
def get_video_description(video_id: str) -> str:
98+
parts = ['statistics', 'contentDetails', 'snippet']
99+
request = get_video(video_id, parts)
100+
raise_api_errors(request)
101+
102+
json = request.json()
38103

39104
data = json['items']
40105
if not data:
41-
return None
106+
raise NoResultsError()
42107

43-
snippet = data[0]['snippet']
44-
statistics = data[0]['statistics']
45-
content_details = data[0]['contentDetails']
108+
item = data[0]
109+
snippet = item['snippet']
110+
statistics = item['statistics']
111+
content_details = item['contentDetails']
46112

47113
out = '\x02{}\x02'.format(snippet['title'])
48114

49115
if not content_details.get('duration'):
50116
return out
51117

52118
length = isodate.parse_duration(content_details['duration'])
53-
out += ' - length \x02{}\x02'.format(timeformat.format_time(int(length.total_seconds()), simple=True))
119+
out += ' - length \x02{}\x02'.format(
120+
timeformat.format_time(int(length.total_seconds()), simple=True)
121+
)
54122
try:
55123
total_votes = float(statistics['likeCount']) + float(statistics['dislikeCount'])
56124
except (LookupError, ValueError):
57125
total_votes = 0
58126

59127
if total_votes != 0:
60128
# format
61-
likes = pluralize_auto(int(statistics['likeCount']), "like")
62-
dislikes = pluralize_auto(int(statistics['dislikeCount']), "dislike")
129+
likes = pluralize_suffix(int(statistics['likeCount']), "like")
130+
dislikes = pluralize_suffix(int(statistics['dislikeCount']), "dislike")
63131

64132
percent = 100 * float(statistics['likeCount']) / total_votes
65-
out += ' - {}, {} (\x02{:.1f}\x02%)'.format(likes,
66-
dislikes, percent)
133+
out += ' - {}, {} (\x02{:.1f}\x02%)'.format(likes, dislikes, percent)
67134

68135
if 'viewCount' in statistics:
69136
views = int(statistics['viewCount'])
70-
out += ' - \x02{:,}\x02 view{}'.format(views, "s"[views == 1:])
137+
out += ' - \x02{:,}\x02 view{}'.format(views, "s"[views == 1 :])
71138

72139
uploader = snippet['channelTitle']
73140

74141
upload_time = isodate.parse_datetime(snippet['publishedAt'])
75-
out += ' - \x02{}\x02 on \x02{}\x02'.format(uploader,
76-
upload_time.strftime("%Y.%m.%d"))
142+
out += ' - \x02{}\x02 on \x02{}\x02'.format(
143+
uploader, upload_time.strftime("%Y.%m.%d")
144+
)
77145

78146
try:
79147
yt_rating = content_details['contentRating']['ytRating']
@@ -86,119 +154,100 @@ def get_video_description(video_id):
86154
return out
87155

88156

89-
def get_video_id(reply, text):
90-
dev_key = bot.config.get_api_key('google_dev_key')
91-
if not dev_key:
92-
return None, "This command requires a Google Developers Console API key."
93-
157+
def get_video_id(text: str) -> str:
94158
try:
95-
request = requests.get(search_api_url, params={'q': text, 'key': dev_key, 'type': 'video'})
96-
request.raise_for_status()
97-
except Exception:
98-
reply("Error performing search.")
99-
raise
159+
request = do_search(text)
160+
except requests.RequestException as e:
161+
raise APIError("Unable to connect to API") from e
100162

163+
raise_api_errors(request)
101164
json = request.json()
102165

103-
if json.get('error'):
104-
if json['error']['code'] == 403:
105-
return None, err_no_api
106-
107-
return None, "Error performing search."
108-
109166
if not json.get('items'):
110-
return None, "No results found."
167+
raise NoResultsError()
111168

112-
video_id = json['items'][0]['id']['videoId']
113-
return video_id, None
169+
video_id = json['items'][0]['id']['videoId'] # type: str
170+
return video_id
114171

115172

116173
@hook.regex(youtube_re)
117-
def youtube_url(match):
174+
def youtube_url(match: Match[str]) -> str:
118175
return get_video_description(match.group(1))
119176

120177

121178
@hook.command("youtube", "you", "yt", "y")
122-
def youtube(text, reply):
179+
def youtube(text: str, reply) -> str:
123180
"""<query> - Returns the first YouTube search result for <query>."""
124-
video_id, err = get_video_id(reply, text)
125-
if err:
126-
return err
127-
128181
try:
129-
return get_video_description(video_id) + " - " + video_url % video_id
182+
video_id = get_video_id(text)
183+
return get_video_description(video_id) + " - " + make_short_url(video_id)
184+
except NoResultsError as e:
185+
return e.message
130186
except APIError as e:
131187
reply(e.message)
132188
raise
133189

134190

135191
@hook.command("youtime", "ytime")
136-
def youtime(text, reply):
192+
def youtime(text: str, reply) -> str:
137193
"""<query> - Gets the total run time of the first YouTube search result for <query>."""
138-
video_id, err = get_video_id(reply, text)
139-
if err:
140-
return err
141-
142-
dev_key = bot.config.get_api_key('google_dev_key')
143-
request = requests.get(api_url.format(video_id, dev_key))
144-
request.raise_for_status()
194+
parts = ['statistics', 'contentDetails', 'snippet']
195+
try:
196+
video_id = get_video_id(text)
197+
request = get_video(video_id, parts)
198+
raise_api_errors(request)
199+
except NoResultsError as e:
200+
return e.message
201+
except APIError as e:
202+
reply(e.message)
203+
raise
145204

146205
json = request.json()
147206

148-
if json.get('error'):
149-
return
150207
data = json['items']
151-
snippet = data[0]['snippet']
152-
content_details = data[0]['contentDetails']
153-
statistics = data[0]['statistics']
208+
item = data[0]
209+
snippet = item['snippet']
210+
content_details = item['contentDetails']
211+
statistics = item['statistics']
154212

155-
if not content_details.get('duration'):
156-
return
213+
duration = content_details.get('duration')
214+
if not duration:
215+
return "Missing duration in API response"
157216

158-
length = isodate.parse_duration(content_details['duration'])
217+
length = isodate.parse_duration(duration)
159218
l_sec = int(length.total_seconds())
160219
views = int(statistics['viewCount'])
161220
total = int(l_sec * views)
162221

163222
length_text = timeformat.format_time(l_sec, simple=True)
164223
total_text = timeformat.format_time(total, accuracy=8)
165224

166-
return 'The video \x02{}\x02 has a length of {} and has been viewed {:,} times for ' \
167-
'a total run time of {}!'.format(snippet['title'], length_text, views,
168-
total_text)
169-
170-
171-
ytpl_re = re.compile(r'(.*:)//(www.youtube.com/playlist|youtube.com/playlist)(:[0-9]+)?(.*)', re.I)
225+
return (
226+
'The video \x02{}\x02 has a length of {} and has been viewed {:,} times for '
227+
'a total run time of {}!'.format(
228+
snippet['title'], length_text, views, total_text
229+
)
230+
)
172231

173232

174233
@hook.regex(ytpl_re)
175-
def ytplaylist_url(match, reply):
234+
def ytplaylist_url(match: Match[str]) -> str:
176235
location = match.group(4).split("=")[-1]
177-
dev_key = bot.config.get_api_key("google_dev_key")
178-
try:
179-
request = requests.get(playlist_api_url, params={"id": location, "key": dev_key})
180-
request.raise_for_status()
181-
except Exception:
182-
reply("Error looking up playlist.")
183-
raise
236+
request = get_playlist(location, ['contentDetails', 'snippet'])
237+
raise_api_errors(request)
184238

185239
json = request.json()
186240

187-
if json.get('error'):
188-
if json['error']['code'] == 403:
189-
return err_no_api
190-
191-
return 'Error looking up playlist.'
192-
193241
data = json['items']
194242
if not data:
195-
return "No results found."
243+
raise NoResultsError()
196244

197-
snippet = data[0]['snippet']
198-
content_details = data[0]['contentDetails']
245+
item = data[0]
246+
snippet = item['snippet']
247+
content_details = item['contentDetails']
199248

200249
title = snippet['title']
201250
author = snippet['channelTitle']
202251
num_videos = int(content_details['itemCount'])
203-
count_videos = ' - \x02{:,}\x02 video{}'.format(num_videos, "s"[num_videos == 1:])
252+
count_videos = ' - \x02{:,}\x02 video{}'.format(num_videos, "s"[num_videos == 1 :])
204253
return "\x02{}\x02 {} - \x02{}\x02".format(title, count_videos, author)

0 commit comments

Comments
 (0)