-
-
Save calzoneman/b5ee12cf69863bd3fcc3 to your computer and use it in GitHub Desktop.
# coding=utf8 | |
""" | |
youtube.py - Willie YouTube v3 Module | |
Copyright (c) 2015 Calvin Montgomery, All rights reserved. | |
Redistribution and use in source and binary forms, with or without modification, | |
are permitted provided that the following conditions are met: | |
Redistributions of source code must retain the above copyright notice, this list | |
of conditions and the following disclaimer. Redistributions in binary form must | |
reproduce the above copyright notice, this list of conditions and the following | |
disclaimer in the documentation and/or other materials provided with the | |
distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND | |
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A | |
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR | |
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, | |
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF | |
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS | |
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN | |
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING | |
IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY | |
OF SUCH DAMAGE. | |
Behavior based on the original YouTube module by the following authors: | |
Dimitri Molenaars, Tyrope.nl. | |
Elad Alfassa, <[email protected]> | |
Edward Powell, embolalia.net | |
Usage: | |
1. Go to https://console.developers.google.com/project | |
2. Create a new API project | |
3. On the left sidebar, click "Credentials" under "APIs & auth" | |
4. Click "Create new Key" under "Public API access" | |
5. Click "Server key" | |
6. Under "APIs & auth" click "YouTube Data API" and then click "Enable API" | |
Once the key has been generated, add the following to your willie config | |
(replace dummy_key with the key obtained from the API console): | |
[youtube] | |
api_key = dummy_key | |
""" | |
import datetime | |
import json | |
import re | |
import sys | |
from willie import web, tools | |
from willie.module import rule, commands, example | |
URL_REGEX = re.compile(r'(youtube.com/watch\S*v=|youtu.be/)([\w-]+)') | |
INFO_URL = ('https://www.googleapis.com/youtube/v3/videos' | |
'?key={}&part=contentDetails,status,snippet,statistics&id={}') | |
SEARCH_URL = (u'https://www.googleapis.com/youtube/v3/search' | |
'?key={}&part=id&maxResults=1&q={}&type=video') | |
class YouTubeError(Exception): | |
pass | |
def setup(bot): | |
if not bot.memory.contains('url_callbacks'): | |
bot.memory['url_callbacks'] = tools.WillieMemory() | |
bot.memory['url_callbacks'][URL_REGEX] = youtube_info | |
def shutdown(bot): | |
del bot.memory['url_callbacks'][URL_REGEX] | |
def get_api_key(bot): | |
if not bot.config.has_option('youtube', 'api_key'): | |
raise KeyError('Missing YouTube API key') | |
return bot.config.youtube.api_key | |
def configure(config): | |
if config.option('Configure YouTube v3 API', False): | |
config.interactive_add('youtube', 'api_key', 'Google Developers ' | |
'Console API key (Server key)') | |
def convert_date(date): | |
"""Parses an ISO 8601 datestamp and reformats it to be a bit nicer""" | |
date = datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%S.000Z') | |
return date.strftime('%Y-%m-%d %H:%M:%S UTC') | |
def convert_duration(duration): | |
"""Converts an ISO 8601 duration to a human-readable duration""" | |
units = { | |
'hour': 0, | |
'minute': 0, | |
'second': 0 | |
} | |
for symbol, unit in zip(('H', 'M', 'S'), ('hour', 'minute', 'second')): | |
match = re.search(r'(\d+)' + symbol, duration) | |
if match: | |
units[unit] = int(match.group(1)) | |
time = datetime.time(**units) | |
output = str(time) | |
match = re.search('(\d+)D', duration) | |
if match: | |
output = match.group(1) + ' days, ' + output | |
return output | |
def fetch_video_info(bot, id): | |
"""Retrieves video metadata from YouTube""" | |
url = INFO_URL.format(get_api_key(bot), id) | |
raw, headers = web.get(url, return_headers=True) | |
if headers['_http_status'] == 403: | |
bot.say(u'[YouTube Search] Access denied. Check that your API key is ' | |
u'configured to allow access to your IP address.') | |
return | |
try: | |
result = json.loads(raw) | |
except ValueError as e: | |
raise YouTubeError(u'Failed to decode: ' + raw) | |
if 'error' in result: | |
raise YouTubeError(result['error']['message']) | |
if len(result['items']) == 0: | |
raise YouTubeError('YouTube API returned empty result') | |
video = result['items'][0] | |
info = { | |
'title': video['snippet']['title'], | |
'uploader': video['snippet']['channelTitle'], | |
'uploaded': convert_date(video['snippet']['publishedAt']), | |
'duration': convert_duration(video['contentDetails']['duration']), | |
'views': video['statistics']['viewCount'], | |
'comments': video['statistics']['commentCount'], | |
'likes': video['statistics']['likeCount'], | |
'dislikes': video['statistics']['dislikeCount'], | |
'link': 'https://youtu.be/' + video['id'] | |
} | |
return info | |
def fix_count(count): | |
"""Adds commas to a number representing a count""" | |
return '{:,}'.format(int(count)) | |
def format_info(tag, info, include_link=False): | |
"""Formats video information for sending to IRC. | |
If include_link is True, then the video link will be included in the | |
output (this is useful for search results), otherwise it is not (no | |
reason to include a link if we are simply printing information about | |
a video that was already linked in chat). | |
""" | |
output = [ | |
u'[{}] Title: {}'.format(tag, info['title']), | |
u'Uploader: ' + info['uploader'], | |
u'Uploaded: ' + info['uploaded'], | |
u'Duration: ' + info['duration'], | |
u'Views: ' + fix_count(info['views']), | |
u'Comments: ' + fix_count(info['comments']), | |
u'Likes: ' + fix_count(info['likes']), | |
u'Dislikes: ' + fix_count(info['dislikes']) | |
] | |
if include_link: | |
output.append(u'Link: ' + info['link']) | |
return u' | '.join(output) | |
@rule('.*(youtube.com/watch\S*v=|youtu.be/)([\w-]+).*') | |
def youtube_info(bot, trigger, found_match=None): | |
"""Catches youtube links said in chat and fetches video information""" | |
match = found_match or trigger | |
try: | |
info = fetch_video_info(bot, match.group(2)) | |
except YouTubeError as e: | |
bot.say(u'[YouTube] Lookup failed: {}'.format(e)) | |
return | |
bot.say(format_info('YouTube', info)) | |
@commands('yt', 'youtube') | |
@example('.yt Mystery Skulls - Ghost') | |
def ytsearch(bot, trigger): | |
"""Allows users to search for YouTube videos with .yt <search query>""" | |
if not trigger.group(2): | |
return | |
# Note that web.get() quotes the query parameters, so the | |
# trigger is purposely left unquoted (double-quoting breaks things) | |
url = SEARCH_URL.format(get_api_key(bot), trigger.group(2)) | |
raw, headers = web.get(url, return_headers=True) | |
if headers['_http_status'] == 403: | |
bot.say(u'[YouTube Search] Access denied. Check that your API key is ' | |
u'configured to allow access to your IP address.') | |
return | |
try: | |
result = json.loads(raw) | |
except ValueError as e: | |
bot.say(u'[YouTube Search] Failed to decode: ' + raw) | |
return | |
if 'error' in result: | |
bot.say(u'[YouTube Search] ' + result['error']['message']) | |
return | |
if len(result['items']) == 0: | |
bot.say(u'[YouTube Search] No results for ' + trigger.group(2)) | |
return | |
# YouTube v3 API does not include useful video metadata in search results. | |
# Searching gives us the video ID, now we have to do a regular lookup to | |
# get the information we want. | |
try: | |
info = fetch_video_info(bot, result['items'][0]['id']['videoId']) | |
except YouTubeError as e: | |
bot.say(u'[YouTube] Lookup failed: {}'.format(e)) | |
return | |
bot.say(format_info('YouTube Search', info, include_link=True)) |
Great work, thank you! Seems like searching with more than one search term returns an error, though.
Here's the traceback.
Traceback (most recent call last):
File "/usr/local/lib/python2.7/dist-packages/willie/bot.py", line 581, in call
exit_code = func(willie, trigger)
File "/usr/local/lib/python2.7/dist-packages/willie/modules/youtube.py", line 180, in ytsearch
result = json.loads(web.get(url))
File "/usr/lib/python2.7/json/__init__.py", line 326, in loads
return _default_decoder.decode(s)
File "/usr/lib/python2.7/json/decoder.py", line 366, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
File "/usr/lib/python2.7/json/decoder.py", line 384, in raw_decode
raise ValueError("No JSON object could be decoded")
ValueError: No JSON object could be decoded
In IRC it spits out just this:
ValueError: No JSON object could be decoded (file "/usr/lib/python2.7/json/decoder.py", line 384, in raw_decode)
Did I miss something?
I'm also getting that JSON error for a string that has spaces in it. If you search with URL entities it works fine. For example
.yt test%20search
would work, but
.yt test search
will throw an error
I apologize for not replying promptly; I didn't realize that GitHub doesn't email me for comments on gists.
@rtil5 Are you sure you're using the latest version of willie? URL escaping is handled by web.get()
, hence test search
should automatically be encoded as test%20search
by that function (and in fact, from my testing, this appears to be the case)
02:28:12 <@calzoneman> .yt test search
02:28:13 <willieDev> [YouTube Search] Title: Google AdWords Exam Practice Test Questions and Answers (Search Advertising Advance) | Uploader: Gokuldas K | Uploaded: 2013-07-25 12:41:30 UTC | Duration: 00:21:45 | Views: 21,247 | Comments: 8 | Likes: 25 | Dislikes: 18 | Link: https://youtu.be/kW1_YIXqmUA
@zwerxyplous Are you sure that you have the API key set up correctly? I may need to add some more debug information to figure out what is going on in your case. I'm guessing you're running into the same issue as @rtil5.
I just updated willie to be sure, and it still doesn't work.
Just had someone else report this issue; it turned out he didn't have his IP address right for the API key.
Updated gist with explicit error handling for HTTP 403.
Here's a new error.
KeyError: '_http_status' (file "/usr/lib64/python2.7/rfc822.py", line 388, in __getitem__)
a KeyError is thrown on line 134 if comments are disabled. there must be an easy fix for this, but i'm not sure what.
here's this crude workaround for now:
try:
info = {
'title': video['snippet']['title'],
'uploader': video['snippet']['channelTitle'],
'uploaded': convert_date(video['snippet']['publishedAt']),
'duration': convert_duration(video['contentDetails']['duration']),
'views': video['statistics']['viewCount'],
'comments': video['statistics']['commentCount'],
'likes': video['statistics']['likeCount'],
'dislikes': video['statistics']['dislikeCount'],
'link': 'https://youtu.be/' + video['id']
}
except KeyError:
info = {
'title': video['snippet']['title'],
'uploader': video['snippet']['channelTitle'],
'uploaded': convert_date(video['snippet']['publishedAt']),
'duration': convert_duration(video['contentDetails']['duration']),
'views': video['statistics']['viewCount'],
'comments': 0,
'likes': video['statistics']['likeCount'],
'dislikes': video['statistics']['dislikeCount'],
'link': 'https://youtu.be/' + video['id']
}
Added that step to the instructions and added error handling.