diff --git a/.gitignore b/.gitignore index 21bf185..64ca004 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ doc/ etc/ include/ lib/ -man/ \ No newline at end of file +man/ +dist/ diff --git a/Ffmpeg.py b/Ffmpeg.py deleted file mode 100644 index 4452221..0000000 --- a/Ffmpeg.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -Ffmpeg.py - -Created by Ichabond on 2012-07-01. -Copyright (c) 2012 Baconseed. All rights reserved. -""" - -import os -import subprocess -import sys -import re - -from tempfile import mkdtemp -from hashlib import md5 - - -class FFMpeg(object): - def __init__(self, filepath): - self.file = filepath - self.ffmpeg = None - self.duration = None - self.tempdir = mkdtemp(prefix="pythonbits-") + os.sep - - def getDuration(self): - try: - self.ffmpeg = subprocess.Popen([r"ffmpeg", "-i", self.file], stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - except OSError: - sys.stderr.write( - "Error: Ffmpeg not installed, refer to http://www.ffmpeg.org/download.html for installation") - exit(1) - ffmpeg_out = self.ffmpeg.stdout.read() - ffmpeg_duration = re.findall(r'Duration:\D(\d{2}):(\d{2}):(\d{2})', ffmpeg_out) - if not ffmpeg_duration: - # the odds of a filename collision on an md5 digest are very small - out_fn = '%s.txt' % md5(ffmpeg_out).hexdigest() - err_f = open(out_fn, 'wb') - err_f.write(ffmpeg_out) - err_f.close() - err_msg = ("Expected ffmpeg to mention 'Duration' but it did not;\n" + - "Please copy the contents of '%s' to http://pastebin.com/\n" + - " and send the pastebin link to the bB forum.") % out_fn - sys.stderr.write(err_msg) - dur = ffmpeg_duration[0] - dur_hh = int(dur[0]) - dur_mm = int(dur[1]) - dur_ss = int(dur[2]) - self.duration = dur_hh * 3600 + dur_mm * 60 + dur_ss - - def takeScreenshots(self, shots): - self.getDuration() - stops = range(20, 81, 60 / (shots - 1)) - imgs = [] - try: - for stop in stops: - imgs.append(self.tempdir + "screen%s.png" % stop) - subprocess.Popen([r"ffmpeg", "-ss", str((self.duration * stop) / 100), "-i", self.file, "-vframes", "1", - "-y", "-f", "image2", "-vf", """scale='max(sar,1)*iw':'max(1/sar,1)*ih'""", imgs[-1]], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate() - except OSError: - sys.stderr.write( - "Error: Ffmpeg not installed, refer to http://www.ffmpeg.org/download.html for installation") - exit(1) - return imgs - diff --git a/ImageUploader.py b/ImageUploader.py deleted file mode 100644 index 3f5461c..0000000 --- a/ImageUploader.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -ImageUploader.py - -Created by Ichabond on 2012-07-01. -""" - -import json -import urllib2 -import MultipartPostHandler -import re - - -class Upload(object): - def __init__(self, filelist): - self.images = filelist - self.imageurl = [] - - def upload(self): - opener = urllib2.build_opener(MultipartPostHandler.MultipartPostHandler) - matcher = re.compile(r'http(s)*://') - try: - for image in self.images: - if matcher.match(image): - params = ({'url': image}) - else: - params = ({'ImageUp': open(image, "rb")}) - socket = opener.open("https://images.baconbits.org/upload.php", params) - json_str = socket.read() - if hasattr(json, 'loads') or hasattr(json, 'read'): - read = json.loads(json_str) - else: - err_msg = "I cannot decipher the provided json\n" + \ - "Please report the following output to the relevant bB forum: \n" + \ - ("%s" % dir(json)) - self.imageurl.append("https://images.baconbits.org/images/" + read["ImgName"]) - except Exception as e: - print e - return self.imageurl \ No newline at end of file diff --git a/ImdbParser.py b/ImdbParser.py deleted file mode 100644 index 88ef51b..0000000 --- a/ImdbParser.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -ImdbParser.py - -Created by Ichabond on 2012-07-01. - -Module for Pythonbits to provide proper and clean -imdb parsing. - -""" - -import sys -import json - -try: - import imdbpie -except ImportError: - print >> sys.stderr, "IMDbPie is required for Pythonbits to function" - sys.exit(1) - - -class IMDB(object): - def __init__(self): - self.imdb = imdbpie.Imdb() - self.results = None - self.movie = None - - def search(self, title): - try: - results = self.imdb.find_by_title(title) - except imdb.IMDbError, e: - print >> sys.stderr, "You probably don't have an internet connection. Complete error report:" - print >> sys.stderr, e - sys.exit(3) - - self.results = results - - def movieSelector(self): - try: - print "Movies found:" - for (counter, movie) in enumerate(self.results): - outp = u'%s: %s (%s)' % (counter, movie['title'], movie['year']) - print outp - selection = int(raw_input('Select the correct movie [0-%s]: ' % (len(self.results) - 1))) - self.movie = self.imdb.find_movie_by_id(self.results[selection]['imdb_id']) - - except ValueError as e: - try: - selection = int( - raw_input("This is not a correct movie-identifier, try again [0-%s]: " % (len(self.results) - 1))) - self.movie = self.imdb.find_movie_by_id(self.results[selection]['imdb_id']) - except (ValueError, IndexError) as e: - print >> sys.stderr, "You failed" - print >> sys.stderr, e - - except IndexError as e: - try: - selection = int( - raw_input("Your chosen value does not match a movie, try again [0-%s]: " % (len(self.results) - 1))) - self.movie = self.imdb.find_movie_by_id(self.results[selection]['imdb_id']) - except (ValueError, IndexError) as e: - print >> sys.stderr, "You failed" - print >> sys.stderr, e - - def summary(self): - if self.movie: - return {'director': u" | ".join([director.name for director in self.movie.directors_summary]), - 'runtime': self.movie.runtime, 'rating': self.movie.rating, - 'name': self.movie.title, 'votes': self.movie.votes, 'cover': self.movie.cover_url, - 'genre': u" | ".join([genre for genre in self.movie.genres]), - 'writers': u" | ".join([writer.name for writer in self.movie.writers_summary]), - 'mpaa': self.movie.certification, - 'description': self.movie.plot_outline, - 'url': u"http://www.imdb.com/title/%s" % self.movie.imdb_id, - 'year': self.movie.year} - - -if __name__ == "__main__": - imdb = IMDB() - imdb.search("Tinker Tailor Soldier Spy") - imdb.movieSelector() - summary = imdb.summary() - print summary diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..3b8984d --- /dev/null +++ b/MANIFEST @@ -0,0 +1,8 @@ +setup.py +bin/pythonbits +pythonbits/Ffmpeg.py +pythonbits/ImageUploader.py +pythonbits/movie.py +pythonbits/Screenshots.py +pythonbits/TvdbParser.py +pythonbits/__init__.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d2d3b26 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +PYTHON=python2 +NOSE=/usr/local/bin/nosetests-2.7 +PIP=/usr/local/bin/pip2 + +all: clean dist install + +dist: + $(PYTHON) setup.py sdist + +install: + -pip2 uninstall pythonbits -y + pip2 install dist/Pythonbits-2.0.0.tar.gz + +clean: + -rm -r dist/ + +test: + $(NOSE) --with-progressive --logging-clear-handlers diff --git a/MultipartPostHandler.py b/MultipartPostHandler.py deleted file mode 100644 index e0d72de..0000000 --- a/MultipartPostHandler.py +++ /dev/null @@ -1,99 +0,0 @@ -# coding = -""" -Usage: - Enables the use of multipart/form-data for posting forms - -Inspirations: - Upload files in python: - http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306 - urllib2_file: - Fabien Seisen: - -Example: - import MultipartPostHandler, urllib2, cookielib - - cookies = cookielib.CookieJar() - opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookies), - MultipartPostHandler.MultipartPostHandler) - params = { "username" : "bob", "password" : "riviera", - "file" : open("filename", "rb") } - opener.open("http://wwww.bobsite.com/upload/", params) - -Further Example: - The main function of this file is a sample which downloads a page and - then uploads it to the W3C validator. -""" - -import urllib -import urllib2 -import mimetools -import mimetypes -import os -import stat - - -class Callable: - def __init__(self, anycallable): - self.__call__ = anycallable - -# Controls how sequences are uncoded. If true, elements may be given multiple values by -# assigning a sequence. -doseq = 1 - - -class MultipartPostHandler(urllib2.BaseHandler): - handler_order = urllib2.HTTPHandler.handler_order - 10 # needs to run first - - def http_request(self, request): - data = request.get_data() - if data is not None and type(data) != str: - v_files = [] - v_vars = [] - try: - for (key, value) in data.items(): - if type(value) == file: - v_files.append((key, value)) - else: - v_vars.append((key, value)) - except TypeError: - systype, value, traceback = sys.exc_info() - raise TypeError, "not a valid non-string sequence or mapping object", traceback - - if len(v_files) == 0: - data = urllib.urlencode(v_vars, doseq) - else: - boundary, data = self.multipart_encode(v_vars, v_files) - contenttype = 'multipart/form-data; boundary=%s' % boundary - if (request.has_header('Content-Type') - and request.get_header('Content-Type').find('multipart/form-data') != 0): - print "Replacing %s with %s" % (request.get_header('content-type'), 'multipart/form-data') - request.add_unredirected_header('Content-Type', contenttype) - - request.add_data(data) - return request - - def multipart_encode(vars, files, boundary=None, buffer=None): - if boundary is None: - boundary = mimetools.choose_boundary() - if buffer is None: - buffer = '' - for (key, value) in vars: - buffer += '--%s\r\n' % boundary - buffer += 'Content-Disposition: form-data; name="%s"' % key - buffer += '\r\n\r\n' + value + '\r\n' - for (key, fd) in files: - file_size = os.fstat(fd.fileno())[stat.ST_SIZE] - filename = os.path.basename(fd.name) - contenttype = mimetypes.guess_type(filename)[0] or 'application/octet-stream' - buffer += '--%s\r\n' % boundary - buffer += 'Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (key, filename) - buffer += 'Content-Type: %s\r\n' % contenttype - # buffer += 'Content-Length: %s\r\n' % file_size - fd.seek(0) - buffer += '\r\n' + fd.read() + '\r\n' - buffer += '--%s--\r\n\r\n' % boundary - return boundary, buffer - - multipart_encode = Callable(multipart_encode) - - https_request = http_request \ No newline at end of file diff --git a/README.md b/README.md index c989ad6..81d9709 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ #### A Python description generator for movies and TV shows ## Install -1. Put all files in your $PATH, and make sure pythonbits.py is executable -2. Use pip (https://github.com/pypa/pip) to install dependencies: pip install -r requirements + $ [sudo] pip install https://github.com/Ichabond/Pythonbits/archive/master.zip + $ pythonbits --help + +Python 2 is required. The correct version of pip may be called `pip2` on some platforms. ## Usage -Use pythonbits.py --help to get a usage overview \ No newline at end of file +Use `pythonbits --help` to get a usage overview diff --git a/TvdbUnitTests.py b/TvdbUnitTests.py deleted file mode 100644 index 59032ba..0000000 --- a/TvdbUnitTests.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -TvdbUnitTests.py - -Created by Ichabond on 2012-07-03. -""" - -import unittest -from TvdbParser import TVDB - - -class TvdbTest(unittest.TestCase): - def setUp(self): - self.maxDiff = None - self.tvdb = TVDB() - self.Episode = self.tvdb.search("Burn Notice", episode="S06E01") - self.Season = self.tvdb.search("Burn Notice", season=6) - self.Show = self.tvdb.search("Scrubs") - - def testEpisode(self): - self.assertEqual(self.Episode["episodename"], "Scorched Earth") - self.assertEqual(self.Episode["director"], "Stephen Surjik") - self.assertEqual(self.Episode['firstaired'], "2012-06-14") - self.assertEqual(self.Episode['writer'], "Matt Nix") - self.assertEqual(len(self.Episode), 26) - - - def testSeason(self): - self.assertEqual(len(self.Season), 10) - - def testShow(self): - self.assertEqual(len(self.Show.data), 25) - self.assertEqual(len(self.Show), 10) - self.assertEqual(self.Show['network'], "ABC") - self.assertEqual(self.Show['seriesname'], "Scrubs") - - def testSummaries(self): - self.tvdb.search("Burn Notice", episode="S06E01") - BurnNoticeS06E01 = {'director': u'Stephen Surjik', 'rating': u'7.9', - 'aired': u'2012-06-14', 'language': u'en', - 'title': u'Scorched Earth', 'genre': u'|Action and Adventure|', - 'summary': u'Michael pursues Anson in Miami. Meanwhile, Fiona is taken into custody and interrogated by a former foe.', - 'writer': u'Matt Nix', - 'url': u'http://thetvdb.com/?tab=episode&seriesid=80270&seasonid=483302&id=4246443', - 'series': u'Burn Notice'} - self.assertEqual(self.tvdb.summary(), BurnNoticeS06E01) - self.tvdb.search("Burn Notice", season=5) - BurnNoticeS05 = {'episode11': u'Better Halves', 'episode17': u'Acceptable Loss', - 'episode15': u'Necessary Evil', 'episode12': u'Dead to Rights', - 'episode13': u'Damned If You Do', 'episode3': u'Mind Games', - 'episode1': u'Company Man', 'episode10': u'Army of One', 'episodes': 18, - 'episode2': u'Bloodlines', 'episode5': u'Square One', 'episode4': u'No Good Deed', - 'episode7': u'Besieged', 'episode6': u'Enemy of My Enemy', - 'episode9': u'Eye for an Eye', 'episode8': u'Hard Out', 'episode18': u'Fail Safe', - 'episode16': u'Depth Perception', 'episode14': u'Breaking Point', - 'url': u'http://thetvdb.com/?tab=season&seriesid=80270&seasonid=463361', - 'series': u'Burn Notice', - 'summary': u'Covert intelligence operative Michael Westen has been punched, kicked, choked and shot. And now he\'s received a "burn notice", blacklisting him from the intelligence community and compromising his very identity. He must track down a faceless nemesis without getting himself killed while doubling as a private investigator on the dangerous streets of Miami in order to survive.'} - self.assertEqual(self.tvdb.summary(), BurnNoticeS05) - self.tvdb.search("Scrubs") - Scrubs = {'rating': u'9.0', 'network': u'ABC', 'series': u'Scrubs', 'contentrating': u'TV-PG', - 'summary': u'Scrubs focuses on the lives of several people working at Sacred Heart, a teaching hospital. It features fast-paced dialogue, slapstick, and surreal vignettes presented mostly as the daydreams of the central character, Dr. John Michael "J.D." Dorian.', - 'seasons': 10, 'url': u'http://thetvdb.com/?tab=series&id=76156'} - self.assertEqual(self.tvdb.summary(), Scrubs) diff --git a/pythonbits.py b/bin/pythonbits similarity index 79% rename from pythonbits.py rename to bin/pythonbits index 4a039ec..06c67ef 100755 --- a/pythonbits.py +++ b/bin/pythonbits @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python2 # encoding: utf-8 """ Pythonbits2.py @@ -12,16 +12,10 @@ import sys import os import subprocess - -import ImdbParser -import TvdbParser - -from Screenshots import createScreenshots - -from ImageUploader import Upload - from optparse import OptionParser +from pythonbits import lookup_movie, TVDB, createScreenshots, upload + def generateSeriesSummary(summary): description = "[b]Description[/b] \n" @@ -62,24 +56,6 @@ def generateSeriesSummary(summary): return description -def generateMoviesSummary(summary): - description = "[b]Description[/b] \n" - description += "[quote]%s[/quote]\n" % summary['description'] - description += "[b]Information:[/b]\n" - description += "[quote]IMDB Url: %s\n" % summary['url'] - description += "Title: %s\n" % summary['name'] - description += "Year: %s\n" % summary['year'] - description += "MPAA: %s\n" % summary['mpaa'] - description += "Rating: %s/10\n" % summary['rating'] - description += "Votes: %s\n" % summary['votes'] - description += "Runtime: %s\n" % summary['runtime'] - description += "Director(s): %s\n" % summary['director'] - description += "Writer(s): %s\n" % summary['writers'] - description += "[/quote]" - - return description - - def findMediaInfo(path): mediainfo = None try: @@ -127,7 +103,7 @@ def main(argv): for shot in screenshot: print shot elif options.season or options.episode: - tvdb = TvdbParser.TVDB() + tvdb = TVDB() if options.season: tvdb.search(search_string, season=options.season) if options.episode: @@ -144,23 +120,15 @@ def main(argv): summary += "[mediainfo]\n%s\n[/mediainfo]" % mediainfo print summary else: - imdb = ImdbParser.IMDB() - imdb.search(search_string) - imdb.movieSelector() - summary = imdb.summary() - movie = generateMoviesSummary(summary) - print "\n\n\n" - print "Year: ", summary['year'] - print "\n\n\n" - print "Movie Description: \n", movie - print "\n\n\n" + movie = lookup_movie(search_string).get_selection() + print movie.summary if not options.info: mediainfo = findMediaInfo(filename) if mediainfo: print "Mediainfo: \n", mediainfo for shot in screenshot: print "Screenshot: %s" % shot - cover = Upload([summary['cover']]).upload() + cover = upload(movie.cover_url) if cover: print "Image (Optional): ", cover[0] diff --git a/pythonbits/Ffmpeg.py b/pythonbits/Ffmpeg.py new file mode 100644 index 0000000..db13560 --- /dev/null +++ b/pythonbits/Ffmpeg.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +# encoding: utf-8 +""" +Ffmpeg.py + +Created by Ichabond on 2012-07-01. +Copyright (c) 2012 Baconseed. All rights reserved. +""" +import sys +import os +import re +import subprocess +from tempfile import mkdtemp, NamedTemporaryFile + + +class FFMpegException(Exception): + pass + + +class FFMpeg(object): + + def __init__(self, filepath): + self.file = filepath + if not os.path.exists(filepath): + raise FFMpegException("File %s does not exist!" % filepath) + + self.tempdir = mkdtemp(prefix="pythonbits-") + + @property + def duration(self): + ffmpeg_data, __ = ffmpeg_wrapper([r"ffmpeg", "-i", self.file], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + + ffmpeg_duration = re.findall( + r'Duration:\D(\d{2}):(\d{2}):(\d{2})', + ffmpeg_data) + if ffmpeg_duration: + hours, minutes, seconds = map(int, ffmpeg_duration[0]) + return hours * 3600 + minutes * 60 + seconds + else: + self._create_dump_and_panic(ffmpeg_data) + + def _create_dump_and_panic(self, ffmpeg_data): + output_file = NamedTemporaryFile(prefix='ffmpeg-error-dump-', + delete=False, dir='', mode='wb') + with output_file as f: + f.write(ffmpeg_data) + + err_msg = ("Expected ffmpeg to mention 'Duration' but it did not. " + "Please copy the contents of '%s' to http://pastebin.com/ " + "and send the pastebin link to the bB forum." + % output_file.name) + raise FFMpegException(err_msg) + + def takeScreenshots(self, shots): + stops = range(20, 81, 60 / (shots - 1)) + imgs = [os.path.join(self.tempdir, "screen%s.png" % stop) + for stop in stops] + + duration = self.duration + for img, stop in zip(imgs, stops): + + ffmpeg_wrapper([r"ffmpeg", + "-ss", str((duration * stop) / 100), + "-i", self.file, + "-vframes", "1", + "-y", + "-f", "image2", + "-vf", """scale='max(sar,1)*iw':'max(1/sar,1)*ih'""", + img], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + return imgs + + +def ffmpeg_wrapper(*args, **kwargs): + try: + return subprocess.Popen(*args, **kwargs).communicate() + except OSError as e: + raise FFMpegException("Error: Ffmpeg not installed, refer to " + "http://www.ffmpeg.org/download.html for " + "installation") diff --git a/pythonbits/ImageUploader.py b/pythonbits/ImageUploader.py new file mode 100644 index 0000000..99c2e85 --- /dev/null +++ b/pythonbits/ImageUploader.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# encoding: utf-8 +""" +ImageUploader.py + +Created by Ichabond on 2012-07-01. +""" + +import requests + + +BASE_URL = 'https://images.baconbits.org/' + + +class BaconBitsImageUploadError(Exception): + pass + + +def upload(file_or_url): + if any(file_or_url.startswith(schema) for schema in + ('http://', 'https://')): + files = {'url': file_or_url} + else: + files = {'ImageUp': open(file_or_url, 'rb')} + + try: + j = requests.post(BASE_URL + 'upload.php', + files=files).json() + except ValueError: + raise BaconBitsImageUploadError("Failed to upload '%s'!" % file_or_url) + + if 'ImgName' in j: + return BASE_URL + 'images/' + j['ImgName'] + else: + raise BaconBitsImageUploadError("Failed to upload '%s'!" % file_or_url, + repr(j)) diff --git a/Screenshots.py b/pythonbits/Screenshots.py similarity index 77% rename from Screenshots.py rename to pythonbits/Screenshots.py index 218ecbc..9a540b2 100644 --- a/Screenshots.py +++ b/pythonbits/Screenshots.py @@ -8,12 +8,12 @@ """ from Ffmpeg import FFMpeg -from ImageUploader import Upload +from ImageUploader import upload def createScreenshots(file, shots=2): ffmpeg = FFMpeg(file) images = ffmpeg.takeScreenshots(shots) - urls = Upload(images).upload() + urls = map(upload, images) - return urls \ No newline at end of file + return urls diff --git a/TvdbParser.py b/pythonbits/TvdbParser.py similarity index 100% rename from TvdbParser.py rename to pythonbits/TvdbParser.py diff --git a/pythonbits/__init__.py b/pythonbits/__init__.py new file mode 100644 index 0000000..6ef63f6 --- /dev/null +++ b/pythonbits/__init__.py @@ -0,0 +1,6 @@ +from movie import lookup_movie +from TvdbParser import TVDB +from Screenshots import createScreenshots +from ImageUploader import upload + +__all__ = ['lookup_movie', 'TVDB', 'createScreenshots', 'upload'] diff --git a/pythonbits/movie.py b/pythonbits/movie.py new file mode 100644 index 0000000..04dc470 --- /dev/null +++ b/pythonbits/movie.py @@ -0,0 +1,102 @@ +# encoding: utf-8 +import sys +from imdbpie import Imdb + + +class MovieLookUpFailed(Exception): + pass + + +class MovieList(object): + def __init__(self, movies): + self.movies = movies + + def print_movies(self): + for index, movie in enumerate(self.movies, start=1): + print u"%s: %s (%s)" % (index, + movie['title'], + movie['year']) + + def get_selection(self): + self.print_movies() + + while True: + try: + user_choice = int(raw_input(u'Select the correct movie [1-%s]: ' + % len(self))) - 1 + except (IndexError, ValueError): + print >> sys.stderr, u"Bad choice!" + else: + if user_choice >= 0 and user_choice < len(self): + break + else: + print >> sys.stderr, u"Bad choice!" + + return Movie(Imdb().get_title_by_id( + self.movies[user_choice]['imdb_id'])) + + def __len__(self): + return len(self.movies) + + +def lookup_movie(movie_name): + movie_matches = Imdb().search_for_title(movie_name) + if not movie_matches: + raise MovieLookUpFailed("No movies matching this name!") + else: + return MovieList(movie_matches) + + +class Movie(object): + def __init__(self, imdb_info): + self._info = imdb_info + + def __getattr__(self, key): + return getattr(self._info, key) + + @property + def summary(self): + if self.directors_summary: + directors = u"\nDirector(s): {}".format( + u" | ".join(d.name for d in self.directors_summary)) + if self.writers_summary: + writers = u"\nWriter(s): {}".format( + u" | ".join(w.name for w in self.writers_summary)) + + + return self.MOVIE_SUMMARY_TEMPLATE.format( + description=self.plot_outline, + url=u"http://www.imdb.com/title/%s" % self.imdb_id, + title=self.title, + year=self.year, + mpaa=self.certification, + rating=self.rating, + votes=self.votes, + runtime=self.runtime, + directors=locals().get('directors', u''), + writers=locals().get('writers', u'')) + + MOVIE_SUMMARY_TEMPLATE = \ + u""" +[b]Description[/b] +[quote] +{description} +[/quote] +[b]Information:[/b] +[quote] +IMDB Url: {url} +Title: {title} +Year: {year} +MPAA: {mpaa} +Rating: {rating}/10 +Votes: {votes} +Runtime: {runtime}{directors}{writers} +[/quote] + + +Year: {year} + + +Movie Description: +{description} +""" diff --git a/pythonbits/test/Ffmpeg_test.py b/pythonbits/test/Ffmpeg_test.py new file mode 100644 index 0000000..e7e2225 --- /dev/null +++ b/pythonbits/test/Ffmpeg_test.py @@ -0,0 +1,56 @@ +import os +import glob +from tempfile import mkdtemp, NamedTemporaryFile +import re +import subprocess + +import mock +from nose.tools import raises + +from pythonbits.Ffmpeg import FFMpeg, FFMpegException + + +FIXTURE_VIDEO = 'pythonbits/test/video.mp4' +_real_popen = subprocess.Popen + + +def popen_no_environ(*args, **kwargs): + kwargs['env'] = {} + return _real_popen(*args, **kwargs) + + +@mock.patch('subprocess.Popen', new=popen_no_environ) +def test_raises_when_no_ffmpeg_in_path(): + f = NamedTemporaryFile() + try: + FFMpeg(f.name).duration() + except FFMpegException as e: + assert 'Ffmpeg not installed' in str(e) + finally: + f.close() + + +def test_raises_when_file_is_invalid(): + f = NamedTemporaryFile() + try: + FFMpeg(f.name).duration() + except FFMpegException as e: + assert 'Duration' in str(e) + finally: + f.close() + + dumpfiles = glob.glob('ffmpeg-error-dump-*') + assert dumpfiles + for file in dumpfiles: + os.unlink(file) + + +def test_parses_correct_duration_as_int(): + assert FFMpeg(FIXTURE_VIDEO).duration == 5 + + +def test_screenshot_files_are_created(): + shots = 2 + image_files = FFMpeg(FIXTURE_VIDEO).takeScreenshots(shots) + assert len(image_files) == shots + assert all(os.path.exists(image) for image in image_files) diff --git a/pythonbits/test/ImageUploader_test.py b/pythonbits/test/ImageUploader_test.py new file mode 100644 index 0000000..b02ddc8 --- /dev/null +++ b/pythonbits/test/ImageUploader_test.py @@ -0,0 +1,56 @@ +import re +import json + +import responses +from nose.tools import raises + +from pythonbits.ImageUploader import upload, BASE_URL, BaconBitsImageUploadError + + +@responses.activate +def test_upload_from_url_returns_valid_url(): + + def response_json_callback(request): + return (200, + {}, + json.dumps({'ImgName': 'image.jpg'})) + + responses.add_callback(responses.POST, + 'https://images.baconbits.org/upload.php', + callback=response_json_callback, + content_type='application/json') + + s = upload('http://example.com/image.jpg') + assert s == 'https://images.baconbits.org/images/image.jpg' + + +@raises(BaconBitsImageUploadError) +@responses.activate +def test_no_json_or_bad_response_raises_err(): + def response_json_callback(request): + return (404, + {}) + + responses.add_callback(responses.POST, + 'https://images.baconbits.org/upload.php', + callback=response_json_callback, + content_type='application/json') + + upload('http://example.com/image.jpg') + + +@raises(BaconBitsImageUploadError) +@responses.activate +def test_missing_key_raises_err(): + + def response_json_callback(request): + return (200, + {}, + json.dumps({'error': 'badly!'})) + + responses.add_callback(responses.POST, + 'https://images.baconbits.org/upload.php', + callback=response_json_callback, + content_type='application/json') + + upload('http://example.com/image.jpg') diff --git a/pythonbits/test/Tvdb_test.py b/pythonbits/test/Tvdb_test.py new file mode 100644 index 0000000..467677b --- /dev/null +++ b/pythonbits/test/Tvdb_test.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# encoding: utf-8 +""" +TvdbUnitTests.py + +Created by Ichabond on 2012-07-03. +""" + +import unittest +from nose.tools import assert_raises + +import tvdb_api + +from pythonbits.TvdbParser import TVDB + + +def test_invalid_episode_identifier_causes_exit(): + tv = TVDB() + tv.tvdb = None + + eps = ("S06 E01","S0601", "601", "S10", "SE10") + for e in eps: + assert_raises(SystemExit, tv.search, "Burn Notice", episode=e) + assert_raises(TypeError, tv.search, "Burn Notice", episode="S01E01") # valid + + +def test_search_for_show_returns_underlying_show(): + # mock out the underlying api call to tvdb_api + tv = TVDB() + tv.tvdb = {'Scrubs': tvdb_api.Show()} + + assert isinstance(tv.search("Scrubs"), tvdb_api.Show) + + +def test_search_for_episode_returns_underlying_episode(): + # mock out the underlying api call to tvdb_api + tv = TVDB() + tv.tvdb = {'Burn Notice': {6: {1: tvdb_api.Episode()}}} + + assert isinstance(tv.search("Burn Notice", episode="S06E01"), tvdb_api.Episode) + + +def test_search_for_season_returns_underlying_season(): + # mock out the underlying api call to tvdb_api + tv = TVDB() + tv.tvdb = {'Burn Notice': {6: tvdb_api.Season()}} + + assert isinstance(tv.search("Burn Notice", season=6), tvdb_api.Season) + + +def test_expected_keys_in_show_summary(): + tv = TVDB() + tv.episode = None + tv.season = None + tv.show = mock_show_builder() + + summary = tv.summary() + expected = ('series', 'seasons', 'network', 'rating', 'contentrating', + 'summary', 'url') + for k in expected: + assert k in summary + + +def mock_show_builder(): + show = tvdb_api.Show() + show['seriesname'] = '' + show['overview'] = '' + show.__len__ = lambda self: 5 + show['network'] = 'ABC' + show['rating'] = '' + show['summary'] = '' + show['contentrating'] = '' + show['id'] = '' + return show + + +def mock_episode_builder(): + episode = tvdb_api.Episode() + episode['episodename'] = '' + episode['director'] = '' + episode['firstaired'] = '' + episode['writer'] = '' + episode['rating'] = '' + episode['overview'] = '' + episode['language'] = '' + episode['seriesid'] = '1' + episode['genre'] = '' + episode['seasonid'] = '' + episode['id'] = '' + return episode + + +def mock_season_builder(): + season = tvdb_api.Season() + season['overview'] = '' + season.episodes = [mock_episode_builder(), mock_episode_builder()] + + +def test_expected_keys_in_episode_summary(): + tv = TVDB() + tv.show = mock_show_builder() + tv.season = None + tv.episode = mock_episode_builder() + tv.tvdb = {1: {'genre': ''}} + + summary = tv.summary() + expected = ('title', 'director', 'aired', 'writer', 'rating', 'summary', 'language', + 'url', 'genre', 'series', 'seriessummary') + for k in expected: + assert k in summary + + +def test_expected_keys_in_season_summary(): + tv = TVDB() + tv.show = mock_show_builder() + tv.season = mock_season_builder() + tv.episode = None + + summary = tv.summary() + expected = ('series', 'url', 'summary') + # doesn't test for the presence of episode\d keys + for k in expected: + assert k in summary, k diff --git a/pythonbits/test/movie_test.py b/pythonbits/test/movie_test.py new file mode 100644 index 0000000..0f00a36 --- /dev/null +++ b/pythonbits/test/movie_test.py @@ -0,0 +1,50 @@ +# encoding: utf-8 +from mock import patch, MagicMock +from nose.tools import raises +from StringIO import StringIO +import re + +from pythonbits.movie import (Movie, lookup_movie, + MovieLookUpFailed) + + +sample_movies_list = [{'title': 'x', 'year': '1'}, + {'title': 'x', 'year': '123'}] + + +@patch('imdbpie.Imdb.search_for_title', return_value=sample_movies_list) +def test_movie_list_created_with_appropriate_length(mock): + l = lookup_movie('example') + assert l.movies == sample_movies_list + assert len(l) == 2 + + +@patch('imdbpie.Imdb.search_for_title', return_value=sample_movies_list) +def test_movie_link_print_movies_prints_appropriate_number_of_lines(movie): + l = lookup_movie('example') + with patch('sys.stdout', new=StringIO()) as out: + l.print_movies() + + output = out.getvalue().strip() + assert output.startswith('1') + assert len(output.split('\n')) == 2 + for line in output.split('\n'): + assert re.match(r'\d+\: .*? \(\d+\)', line) + + +@raises(MovieLookUpFailed) +@patch('imdbpie.Imdb.search_for_title', return_value=[]) +def test_lookup_raises_error_when_no_matches(mock): + lookup_movie('example') + + +def test_summary_returns_unicode_object(): + m = MagicMock() + + movie = Movie({}) + with patch.object(movie, '_info', m): + output = movie.summary + + assert 'Year' in output + assert '[/quote]' in output + assert isinstance(output, unicode) diff --git a/pythonbits/test/video.mp4 b/pythonbits/test/video.mp4 new file mode 100644 index 0000000..1f625ea Binary files /dev/null and b/pythonbits/test/video.mp4 differ diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..3c9b72a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +nose==1.3.6 +nose-progressive==1.5.1 +responses==0.3.0 +mock==1.0.1 +-r ./requirements.txt diff --git a/requirements b/requirements.txt similarity index 75% rename from requirements rename to requirements.txt index 9598f74..b7bc940 100644 --- a/requirements +++ b/requirements.txt @@ -1,4 +1,4 @@ -imdbpie==1.4.4 +imdbpie>=2.0.0 requests==2.3.0 tvdb-api==1.9 wsgiref==0.1.2 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4fe37da --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +from distutils.core import setup + + +setup( + name='Pythonbits', + author='Ichabond', + version='2.0.0', + packages=['pythonbits'], + scripts=['bin/pythonbits'], + url='https://github.com/Ichabond/Pythonbits', + license='LICENSE', + description='A Python pretty printer for generating attractive movie descriptions with screenshots.', + install_requires=[ + "imdbpie >= 2.0.0", + "requests >= 2.3.0", + "tvdb-api == 1.9", + "wsgiref>=0.1.2" + ], +)