Initial Commit

This commit is contained in:
Tim 2015-02-22 18:32:50 +02:00
commit 88daa3fb91
1311 changed files with 256240 additions and 0 deletions

336
plexpy/__init__.py Normal file
View file

@ -0,0 +1,336 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of
# XBian - XBMC on the Raspberry Pi
import os
import sys
import subprocess
import threading
import webbrowser
import sqlite3
import cherrypy
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from plexpy import versioncheck, logger
import plexpy.config
PROG_DIR = None
FULL_PATH = None
ARGS = None
SIGNAL = None
SYS_PLATFORM = None
SYS_ENCODING = None
QUIET = False
VERBOSE = True
DAEMON = False
CREATEPID = False
PIDFILE = None
SCHED = BackgroundScheduler()
SCHED_LOCK = threading.Lock()
INIT_LOCK = threading.Lock()
_INITIALIZED = False
started = False
DATA_DIR = None
CONFIG = None
DB_FILE = None
LOG_LIST = []
INSTALL_TYPE = None
CURRENT_VERSION = None
LATEST_VERSION = None
COMMITS_BEHIND = None
UMASK = None
def initialize(config_file):
with INIT_LOCK:
global CONFIG
global _INITIALIZED
global CURRENT_VERSION
global LATEST_VERSION
global UMASK
CONFIG = plexpy.config.Config(config_file)
assert CONFIG is not None
if _INITIALIZED:
return False
if CONFIG.HTTP_PORT < 21 or CONFIG.HTTP_PORT > 65535:
plexpy.logger.warn(
'HTTP_PORT out of bounds: 21 < %s < 65535', CONFIG.HTTP_PORT)
CONFIG.HTTP_PORT = 8181
if CONFIG.HTTPS_CERT == '':
CONFIG.HTTPS_CERT = os.path.join(DATA_DIR, 'server.crt')
if CONFIG.HTTPS_KEY == '':
CONFIG.HTTPS_KEY = os.path.join(DATA_DIR, 'server.key')
if not CONFIG.LOG_DIR:
CONFIG.LOG_DIR = os.path.join(DATA_DIR, 'logs')
if not os.path.exists(CONFIG.LOG_DIR):
try:
os.makedirs(CONFIG.LOG_DIR)
except OSError:
CONFIG.LOG_DIR = None
if not QUIET:
sys.stderr.write("Unable to create the log directory. " \
"Logging to screen only.\n")
# Start the logger, disable console if needed
logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR,
verbose=VERBOSE)
if not CONFIG.CACHE_DIR:
# Put the cache dir in the data dir for now
CONFIG.CACHE_DIR = os.path.join(DATA_DIR, 'cache')
if not os.path.exists(CONFIG.CACHE_DIR):
try:
os.makedirs(CONFIG.CACHE_DIR)
except OSError as e:
logger.error("Could not create cache dir '%s': %s", DATA_DIR, e)
# Initialize the database
logger.info('Checking to see if the database has all tables....')
try:
dbcheck()
except Exception as e:
logger.error("Can't connect to the database: %s", e)
# Get the currently installed version. Returns None, 'win32' or the git
# hash.
CURRENT_VERSION, CONFIG.GIT_BRANCH = versioncheck.getVersion()
# Write current version to a file, so we know which version did work.
# This allowes one to restore to that version. The idea is that if we
# arrive here, most parts of PlexPy seem to work.
if CURRENT_VERSION:
version_lock_file = os.path.join(DATA_DIR, "version.lock")
try:
with open(version_lock_file, "w") as fp:
fp.write(CURRENT_VERSION)
except IOError as e:
logger.error("Unable to write current version to file '%s': %s",
version_lock_file, e)
# Check for new versions
if CONFIG.CHECK_GITHUB_ON_STARTUP:
try:
LATEST_VERSION = versioncheck.checkGithub()
except:
logger.exception("Unhandled exception")
LATEST_VERSION = CURRENT_VERSION
else:
LATEST_VERSION = CURRENT_VERSION
# Store the original umask
UMASK = os.umask(0)
os.umask(UMASK)
_INITIALIZED = True
return True
def daemonize():
if threading.activeCount() != 1:
logger.warn(
'There are %r active threads. Daemonizing may cause'
' strange behavior.',
threading.enumerate())
sys.stdout.flush()
sys.stderr.flush()
# Do first fork
try:
pid = os.fork() # @UndefinedVariable - only available in UNIX
if pid != 0:
sys.exit(0)
except OSError, e:
raise RuntimeError("1st fork failed: %s [%d]", e.strerror, e.errno)
os.setsid()
# Make sure I can read my own files and shut out others
prev = os.umask(0) # @UndefinedVariable - only available in UNIX
os.umask(prev and int('077', 8))
# Make the child a session-leader by detaching from the terminal
try:
pid = os.fork() # @UndefinedVariable - only available in UNIX
if pid != 0:
sys.exit(0)
except OSError, e:
raise RuntimeError("2nd fork failed: %s [%d]", e.strerror, e.errno)
dev_null = file('/dev/null', 'r')
os.dup2(dev_null.fileno(), sys.stdin.fileno())
si = open('/dev/null', "r")
so = open('/dev/null', "a+")
se = open('/dev/null', "a+")
os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno())
pid = os.getpid()
logger.info('Daemonized to PID: %d', pid)
if CREATEPID:
logger.info("Writing PID %d to %s", pid, PIDFILE)
with file(PIDFILE, 'w') as fp:
fp.write("%s\n" % pid)
def launch_browser(host, port, root):
if host == '0.0.0.0':
host = 'localhost'
if CONFIG.ENABLE_HTTPS:
protocol = 'https'
else:
protocol = 'http'
try:
webbrowser.open('%s://%s:%i%s' % (protocol, host, port, root))
except Exception as e:
logger.error('Could not launch browser: %s', e)
def initialize_scheduler():
"""
Start the scheduled background tasks. Re-schedule if interval settings changed.
"""
with SCHED_LOCK:
# Check if scheduler should be started
start_jobs = not len(SCHED.get_jobs())
#Update check
if CONFIG.CHECK_GITHUB_INTERVAL:
minutes = CONFIG.CHECK_GITHUB_INTERVAL
else:
minutes = 0
schedule_job(versioncheck.checkGithub, 'Check GitHub for updates', hours=0, minutes=minutes)
# Start scheduler
if start_jobs and len(SCHED.get_jobs()):
try:
SCHED.start()
except Exception as e:
logger.info(e)
# Debug
#SCHED.print_jobs()
def schedule_job(function, name, hours=0, minutes=0):
"""
Start scheduled job if starting or restarting plexpy.
Reschedule job if Interval Settings have changed.
Remove job if if Interval Settings changed to 0
"""
job = SCHED.get_job(name)
if job:
if hours == 0 and minutes == 0:
SCHED.remove_job(name)
logger.info("Removed background task: %s", name)
elif job.trigger.interval != datetime.timedelta(hours=hours, minutes=minutes):
SCHED.reschedule_job(name, trigger=IntervalTrigger(
hours=hours, minutes=minutes))
logger.info("Re-scheduled background task: %s", name)
elif hours > 0 or minutes > 0:
SCHED.add_job(function, id=name, trigger=IntervalTrigger(
hours=hours, minutes=minutes))
logger.info("Scheduled background task: %s", name)
def start():
global started
if _INITIALIZED:
initialize_scheduler()
started = True
def sig_handler(signum=None, frame=None):
if signum is not None:
logger.info("Signal %i caught, saving and exiting...", signum)
shutdown()
def dbcheck():
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
conn.commit()
c.close()
def shutdown(restart=False, update=False):
cherrypy.engine.exit()
SCHED.shutdown(wait=False)
CONFIG.write()
if not restart and not update:
logger.info('PlexPy is shutting down...')
if update:
logger.info('PlexPy is updating...')
try:
versioncheck.update()
except Exception as e:
logger.warn('PlexPy failed to update: %s. Restarting.', e)
if CREATEPID:
logger.info('Removing pidfile %s', PIDFILE)
os.remove(PIDFILE)
if restart:
logger.info('PlexPy is restarting...')
popen_list = [sys.executable, FULL_PATH]
popen_list += ARGS
if '--nolaunch' not in popen_list:
popen_list += ['--nolaunch']
logger.info('Restarting PlexPy with %s', popen_list)
subprocess.Popen(popen_list, cwd=os.getcwd())
os._exit(0)

128
plexpy/api.py Normal file
View file

@ -0,0 +1,128 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
from plexpy import db, updater, cache, versioncheck, logger
import plexpy
import json
cmd_list = ['getLogs', 'getVersion', 'checkGithub', 'shutdown', 'restart', 'update']
class Api(object):
def __init__(self):
self.apikey = None
self.cmd = None
self.id = None
self.kwargs = None
self.data = None
self.callback = None
def checkParams(self, *args, **kwargs):
if not plexpy.CONFIG.API_ENABLED:
self.data = 'API not enabled'
return
if not plexpy.CONFIG.API_KEY:
self.data = 'API key not generated'
return
if len(plexpy.CONFIG.API_KEY) != 32:
self.data = 'API key not generated correctly'
return
if 'apikey' not in kwargs:
self.data = 'Missing api key'
return
if kwargs['apikey'] != plexpy.CONFIG.API_KEY:
self.data = 'Incorrect API key'
return
else:
self.apikey = kwargs.pop('apikey')
if 'cmd' not in kwargs:
self.data = 'Missing parameter: cmd'
return
if kwargs['cmd'] not in cmd_list:
self.data = 'Unknown command: %s' % kwargs['cmd']
return
else:
self.cmd = kwargs.pop('cmd')
self.kwargs = kwargs
self.data = 'OK'
def fetchData(self):
if self.data == 'OK':
logger.info('Recieved API command: %s', self.cmd)
methodToCall = getattr(self, "_" + self.cmd)
methodToCall(**self.kwargs)
if 'callback' not in self.kwargs:
if isinstance(self.data, basestring):
return self.data
else:
return json.dumps(self.data)
else:
self.callback = self.kwargs['callback']
self.data = json.dumps(self.data)
self.data = self.callback + '(' + self.data + ');'
return self.data
else:
return self.data
def _dic_from_query(self, query):
myDB = db.DBConnection()
rows = myDB.select(query)
rows_as_dic = []
for row in rows:
row_as_dic = dict(zip(row.keys(), row))
rows_as_dic.append(row_as_dic)
return rows_as_dic
def _getLogs(self, **kwargs):
pass
def _getVersion(self, **kwargs):
self.data = {
'git_path': plexpy.CONFIG.GIT_PATH,
'install_type': plexpy.INSTALL_TYPE,
'current_version': plexpy.CURRENT_VERSION,
'latest_version': plexpy.LATEST_VERSION,
'commits_behind': plexpy.COMMITS_BEHIND,
}
def _checkGithub(self, **kwargs):
versioncheck.checkGithub()
self._getVersion()
def _shutdown(self, **kwargs):
plexpy.SIGNAL = 'shutdown'
def _restart(self, **kwargs):
plexpy.SIGNAL = 'restart'
def _update(self, **kwargs):
plexpy.SIGNAL = 'update'

483
plexpy/cache.py Normal file
View file

@ -0,0 +1,483 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
import os
import plexpy
from plexpy import db, helpers, logger, request
LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4"
class Cache(object):
"""
This class deals with getting, storing and serving up artwork (album art,
artist images, etc) and info/descriptions (album info, artist descrptions)
to and from the cache folder. This can be called from within a web
interface. For example, using the helper functions `getInfo(id)` and
`getArtwork(id)`, to utilize the cached images rather than having to
retrieve them every time the page is reloaded.
You can call `getArtwork(id)` which will return an absolute path to the
image file on the local machine, or if the cache directory doesn't exist,
or can not be written to, it will return a url to the image.
Call `getInfo(id)` to grab the artist/album info. This will return the
text description.
The basic format for art in the cache is `<musicbrainzid>.<date>.<ext>`
and for info it is `<musicbrainzid>.<date>.txt`
"""
path_to_art_cache = os.path.join(plexpy.CONFIG.CACHE_DIR, 'artwork')
def __init__(self):
self.id = None
self.id_type = None # 'artist' or 'album' - set automatically depending on whether ArtistID or AlbumID is passed
self.query_type = None # 'artwork','thumb' or 'info' - set automatically
self.artwork_files = []
self.thumb_files = []
self.artwork_errors = False
self.artwork_url = None
self.thumb_errors = False
self.thumb_url = None
self.info_summary = None
self.info_content = None
def _findfilesstartingwith(self, pattern, folder):
files = []
if os.path.exists(folder):
for fname in os.listdir(folder):
if fname.startswith(pattern):
files.append(os.path.join(folder, fname))
return files
def _exists(self, type):
self.artwork_files = []
self.thumb_files = []
if type == 'artwork':
self.artwork_files = self._findfilesstartingwith(self.id, self.path_to_art_cache)
if self.artwork_files:
return True
else:
return False
elif type == 'thumb':
self.thumb_files = self._findfilesstartingwith("T_" + self.id, self.path_to_art_cache)
if self.thumb_files:
return True
else:
return False
def _get_age(self, date):
# There's probably a better way to do this
split_date = date.split('-')
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
return days_old
def _is_current(self, filename=None, date=None):
if filename:
base_filename = os.path.basename(filename)
date = base_filename.split('.')[1]
# Calculate how old the cached file is based on todays date & file date stamp
# helpers.today() returns todays date in yyyy-mm-dd format
if self._get_age(helpers.today()) - self._get_age(date) < 30:
return True
else:
return False
def _get_thumb_url(self, data):
thumb_url = None
try:
images = data[self.id_type]['image']
except KeyError:
return None
for image in images:
if image['size'] == 'medium':
thumb_url = image['#text']
break
return thumb_url
def get_artwork_from_cache(self, ArtistID=None, AlbumID=None):
"""
Pass a musicbrainz id to this function (either ArtistID or AlbumID)
"""
self.query_type = 'artwork'
if ArtistID:
self.id = ArtistID
self.id_type = 'artist'
else:
self.id = AlbumID
self.id_type = 'album'
if self._exists('artwork') and self._is_current(filename=self.artwork_files[0]):
return self.artwork_files[0]
else:
self._update_cache()
# If we failed to get artwork, either return the url or the older file
if self.artwork_errors and self.artwork_url:
return self.artwork_url
elif self._exists('artwork'):
return self.artwork_files[0]
else:
return None
def get_thumb_from_cache(self, ArtistID=None, AlbumID=None):
"""
Pass a musicbrainz id to this function (either ArtistID or AlbumID)
"""
self.query_type = 'thumb'
if ArtistID:
self.id = ArtistID
self.id_type = 'artist'
else:
self.id = AlbumID
self.id_type = 'album'
if self._exists('thumb') and self._is_current(filename=self.thumb_files[0]):
return self.thumb_files[0]
else:
self._update_cache()
# If we failed to get artwork, either return the url or the older file
if self.thumb_errors and self.thumb_url:
return self.thumb_url
elif self._exists('thumb'):
return self.thumb_files[0]
else:
return None
def get_info_from_cache(self, ArtistID=None, AlbumID=None):
self.query_type = 'info'
myDB = db.DBConnection()
if ArtistID:
self.id = ArtistID
self.id_type = 'artist'
db_info = myDB.action('SELECT Summary, Content, LastUpdated FROM descriptions WHERE ArtistID=?', [self.id]).fetchone()
else:
self.id = AlbumID
self.id_type = 'album'
db_info = myDB.action('SELECT Summary, Content, LastUpdated FROM descriptions WHERE ReleaseGroupID=?', [self.id]).fetchone()
if not db_info or not db_info['LastUpdated'] or not self._is_current(date=db_info['LastUpdated']):
self._update_cache()
info_dict = {'Summary': self.info_summary, 'Content': self.info_content}
return info_dict
else:
info_dict = {'Summary': db_info['Summary'], 'Content': db_info['Content']}
return info_dict
def get_image_links(self, ArtistID=None, AlbumID=None):
"""
Here we're just going to open up the last.fm url, grab the image links and return them
Won't save any image urls, or save the artwork in the cache. Useful for search results, etc.
"""
if ArtistID:
self.id_type = 'artist'
data = lastfm.request_lastfm("artist.getinfo", mbid=ArtistID, api_key=LASTFM_API_KEY)
if not data:
return
try:
image_url = data['artist']['image'][-1]['#text']
except (KeyError, IndexError):
logger.debug('No artist image found')
image_url = None
thumb_url = self._get_thumb_url(data)
if not thumb_url:
logger.debug('No artist thumbnail image found')
else:
self.id_type = 'album'
data = lastfm.request_lastfm("album.getinfo", mbid=AlbumID, api_key=LASTFM_API_KEY)
if not data:
return
try:
image_url = data['album']['image'][-1]['#text']
except (KeyError, IndexError):
logger.debug('No album image found on last.fm')
image_url = None
thumb_url = self._get_thumb_url(data)
if not thumb_url:
logger.debug('No album thumbnail image found on last.fm')
return {'artwork': image_url, 'thumbnail': thumb_url}
def remove_from_cache(self, ArtistID=None, AlbumID=None):
"""
Pass a musicbrainz id to this function (either ArtistID or AlbumID)
"""
if ArtistID:
self.id = ArtistID
self.id_type = 'artist'
else:
self.id = AlbumID
self.id_type = 'album'
self.query_type = 'artwork'
if self._exists('artwork'):
for artwork_file in self.artwork_files:
try:
os.remove(artwork_file)
except:
logger.warn('Error deleting file from the cache: %s', artwork_file)
self.query_type = 'thumb'
if self._exists('thumb'):
for thumb_file in self.thumb_files:
try:
os.remove(thumb_file)
except Exception:
logger.warn('Error deleting file from the cache: %s', thumb_file)
def _update_cache(self):
"""
Since we call the same url for both info and artwork, we'll update both at the same time
"""
myDB = db.DBConnection()
# Since lastfm uses release ids rather than release group ids for albums, we have to do a artist + album search for albums
# Exception is when adding albums manually, then we should use release id
if self.id_type == 'artist':
data = lastfm.request_lastfm("artist.getinfo", mbid=self.id, api_key=LASTFM_API_KEY)
if not data:
return
try:
self.info_summary = data['artist']['bio']['summary']
except KeyError:
logger.debug('No artist bio summary found')
self.info_summary = None
try:
self.info_content = data['artist']['bio']['content']
except KeyError:
logger.debug('No artist bio found')
self.info_content = None
try:
image_url = data['artist']['image'][-1]['#text']
except KeyError:
logger.debug('No artist image found')
image_url = None
thumb_url = self._get_thumb_url(data)
if not thumb_url:
logger.debug('No artist thumbnail image found')
else:
dbalbum = myDB.action('SELECT ArtistName, AlbumTitle, ReleaseID FROM albums WHERE AlbumID=?', [self.id]).fetchone()
if dbalbum['ReleaseID'] != self.id:
data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID'], api_key=LASTFM_API_KEY)
if not data:
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], album=dbalbum['AlbumTitle'], api_key=LASTFM_API_KEY)
else:
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], album=dbalbum['AlbumTitle'], api_key=LASTFM_API_KEY)
if not data:
return
try:
self.info_summary = data['album']['wiki']['summary']
except KeyError:
logger.debug('No album summary found')
self.info_summary = None
try:
self.info_content = data['album']['wiki']['content']
except KeyError:
logger.debug('No album infomation found')
self.info_content = None
try:
image_url = data['album']['image'][-1]['#text']
except KeyError:
logger.debug('No album image link found')
image_url = None
thumb_url = self._get_thumb_url(data)
if not thumb_url:
logger.debug('No album thumbnail image found')
# Save the content & summary to the database no matter what if we've
# opened up the url
if self.id_type == 'artist':
controlValueDict = {"ArtistID": self.id}
else:
controlValueDict = {"ReleaseGroupID": self.id}
newValueDict = {"Summary": self.info_summary,
"Content": self.info_content,
"LastUpdated": helpers.today()}
myDB.upsert("descriptions", newValueDict, controlValueDict)
# Save the image URL to the database
if image_url:
if self.id_type == 'artist':
myDB.action('UPDATE artists SET ArtworkURL=? WHERE ArtistID=?', [image_url, self.id])
else:
myDB.action('UPDATE albums SET ArtworkURL=? WHERE AlbumID=?', [image_url, self.id])
# Save the thumb URL to the database
if thumb_url:
if self.id_type == 'artist':
myDB.action('UPDATE artists SET ThumbURL=? WHERE ArtistID=?', [thumb_url, self.id])
else:
myDB.action('UPDATE albums SET ThumbURL=? WHERE AlbumID=?', [thumb_url, self.id])
# Should we grab the artwork here if we're just grabbing thumbs or
# info? Probably not since the files can be quite big
if image_url and self.query_type == 'artwork':
artwork = request.request_content(image_url, timeout=20)
if artwork:
# Make sure the artwork dir exists:
if not os.path.isdir(self.path_to_art_cache):
try:
os.makedirs(self.path_to_art_cache)
os.chmod(self.path_to_art_cache, int(plexpy.CONFIG.FOLDER_PERMISSIONS, 8))
except OSError as e:
logger.error('Unable to create artwork cache dir. Error: %s', e)
self.artwork_errors = True
self.artwork_url = image_url
# Delete the old stuff
for artwork_file in self.artwork_files:
try:
os.remove(artwork_file)
except:
logger.error('Error deleting file from the cache: %s', artwork_file)
ext = os.path.splitext(image_url)[1]
artwork_path = os.path.join(self.path_to_art_cache, self.id + '.' + helpers.today() + ext)
try:
with open(artwork_path, 'wb') as f:
f.write(artwork)
os.chmod(artwork_path, int(plexpy.CONFIG.FILE_PERMISSIONS, 8))
except (OSError, IOError) as e:
logger.error('Unable to write to the cache dir: %s', e)
self.artwork_errors = True
self.artwork_url = image_url
# Grab the thumbnail as well if we're getting the full artwork (as long
# as it's missing/outdated.
if thumb_url and self.query_type in ['thumb', 'artwork'] and not (self.thumb_files and self._is_current(self.thumb_files[0])):
artwork = request.request_content(thumb_url, timeout=20)
if artwork:
# Make sure the artwork dir exists:
if not os.path.isdir(self.path_to_art_cache):
try:
os.makedirs(self.path_to_art_cache)
os.chmod(self.path_to_art_cache, int(plexpy.CONFIG.FOLDER_PERMISSIONS, 8))
except OSError as e:
logger.error('Unable to create artwork cache dir. Error: %s' + e)
self.thumb_errors = True
self.thumb_url = thumb_url
# Delete the old stuff
for thumb_file in self.thumb_files:
try:
os.remove(thumb_file)
except OSError as e:
logger.error('Error deleting file from the cache: %s', thumb_file)
ext = os.path.splitext(image_url)[1]
thumb_path = os.path.join(self.path_to_art_cache, 'T_' + self.id + '.' + helpers.today() + ext)
try:
with open(thumb_path, 'wb') as f:
f.write(artwork)
os.chmod(thumb_path, int(plexpy.CONFIG.FILE_PERMISSIONS, 8))
except (OSError, IOError) as e:
logger.error('Unable to write to the cache dir: %s', e)
self.thumb_errors = True
self.thumb_url = image_url
def getArtwork(ArtistID=None, AlbumID=None):
c = Cache()
artwork_path = c.get_artwork_from_cache(ArtistID, AlbumID)
if not artwork_path:
return None
if artwork_path.startswith('http://'):
return artwork_path
else:
artwork_file = os.path.basename(artwork_path)
return "cache/artwork/" + artwork_file
def getThumb(ArtistID=None, AlbumID=None):
c = Cache()
artwork_path = c.get_thumb_from_cache(ArtistID, AlbumID)
if not artwork_path:
return None
if artwork_path.startswith('http://'):
return artwork_path
else:
thumbnail_file = os.path.basename(artwork_path)
return "cache/artwork/" + thumbnail_file
def getInfo(ArtistID=None, AlbumID=None):
c = Cache()
info_dict = c.get_info_from_cache(ArtistID, AlbumID)
return info_dict
def getImageLinks(ArtistID=None, AlbumID=None):
c = Cache()
image_links = c.get_image_links(ArtistID, AlbumID)
return image_links

67
plexpy/classes.py Normal file
View file

@ -0,0 +1,67 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
#########################################
## Stolen from Sick-Beard's classes.py ##
#########################################
import urllib
from common import USER_AGENT
class PlexPyURLopener(urllib.FancyURLopener):
version = USER_AGENT
class AuthURLOpener(PlexPyURLopener):
"""
URLOpener class that supports http auth without needing interactive password entry.
If the provided username/password don't work it simply fails.
user: username to use for HTTP auth
pw: password to use for HTTP auth
"""
def __init__(self, user, pw):
self.username = user
self.password = pw
# remember if we've tried the username/password before
self.numTries = 0
# call the base class
urllib.FancyURLopener.__init__(self)
def prompt_user_passwd(self, host, realm):
"""
Override this function and instead of prompting just give the
username/password that were provided when the class was instantiated.
"""
# if this is the first try then provide a username/password
if self.numTries == 0:
self.numTries = 1
return (self.username, self.password)
# if we've tried before then return blank which cancels the request
else:
return ('', '')
# this is pretty much just a hack for convenience
def openit(self, url):
self.numTries = 0
return PlexPyURLopener.open(self, url)

48
plexpy/common.py Normal file
View file

@ -0,0 +1,48 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
'''
Created on Aug 1, 2011
@author: Michael
'''
import platform
import operator
import os
import re
from plexpy import version
#Identify Our Application
USER_AGENT = 'PlexPy/-' + version.PLEXPY_VERSION + ' (' + platform.system() + ' ' + platform.release() + ')'
### Notification Types
NOTIFY_SNATCH = 1
NOTIFY_DOWNLOAD = 2
notifyStrings = {}
notifyStrings[NOTIFY_SNATCH] = "Started Download"
notifyStrings[NOTIFY_DOWNLOAD] = "Download Finished"
### Release statuses
UNKNOWN = -1 # should never happen
UNAIRED = 1 # releases that haven't dropped yet
SNATCHED = 2 # qualified with quality
WANTED = 3 # releases we don't have but want to get
DOWNLOADED = 4 # qualified with quality
SKIPPED = 5 # releases we don't want
ARCHIVED = 6 # releases that you don't have locally (counts toward download completion stats)
IGNORED = 7 # releases that you don't want included in your download stats
SNATCHED_PROPER = 9 # qualified with quality

209
plexpy/config.py Normal file
View file

@ -0,0 +1,209 @@
import plexpy.logger
import itertools
import os
import re
from configobj import ConfigObj
def bool_int(value):
"""
Casts a config value into a 0 or 1
"""
if isinstance(value, basestring):
if value.lower() in ('', '0', 'false', 'f', 'no', 'n', 'off'):
value = 0
return int(bool(value))
_CONFIG_DEFINITIONS = {
'DATE_FORMAT': (str, 'General', 'YYYY-MM-DD'),
'GROUPING_GLOBAL_HISTORY': (int, 'PlexWatch', 0),
'GROUPING_USER_HISTORY': (int, 'PlexWatch', 0),
'GROUPING_CHARTS': (int, 'PlexWatch', 0),
'PLEXWATCH_DATABASE': (str, 'PlexWatch', ''),
'PMS_IP': (str, 'PMS', '127.0.0.1'),
'PMS_PORT': (int, 'PMS', 32400),
'PMS_PASSWORD': (str, 'PMS', ''),
'PMS_USERNAME': (str, 'PMS', ''),
'TIME_FORMAT': (str, 'General', 'HH:mm'),
'API_ENABLED': (int, 'General', 0),
'API_KEY': (str, 'General', ''),
'BOXCAR_ENABLED': (int, 'Boxcar', 0),
'BOXCAR_TOKEN': (str, 'Boxcar', ''),
'CACHE_DIR': (str, 'General', ''),
'CACHE_SIZEMB': (int, 'Advanced', 32),
'CHECK_GITHUB': (int, 'General', 1),
'CHECK_GITHUB_INTERVAL': (int, 'General', 360),
'CHECK_GITHUB_ON_STARTUP': (int, 'General', 1),
'CLEANUP_FILES': (int, 'General', 0),
'CONFIG_VERSION': (str, 'General', '0'),
'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0),
'EMAIL_ENABLED': (int, 'Email', 0),
'EMAIL_FROM': (str, 'Email', ''),
'EMAIL_TO': (str, 'Email', ''),
'EMAIL_SMTP_SERVER': (str, 'Email', ''),
'EMAIL_SMTP_USER': (str, 'Email', ''),
'EMAIL_SMTP_PASSWORD': (str, 'Email', ''),
'EMAIL_SMTP_PORT': (int, 'Email', 25),
'EMAIL_TLS': (int, 'Email', 0),
'ENABLE_HTTPS': (int, 'General', 0),
'FREEZE_DB': (int, 'General', 0),
'GIT_BRANCH': (str, 'General', 'master'),
'GIT_PATH': (str, 'General', ''),
'GIT_USER': (str, 'General', 'drzoidberg33'),
'GROWL_ENABLED': (int, 'Growl', 0),
'GROWL_HOST': (str, 'Growl', ''),
'GROWL_PASSWORD': (str, 'Growl', ''),
'HTTPS_CERT': (str, 'General', ''),
'HTTPS_KEY': (str, 'General', ''),
'HTTP_HOST': (str, 'General', '0.0.0.0'),
'HTTP_PASSWORD': (str, 'General', ''),
'HTTP_PORT': (int, 'General', 8181),
'HTTP_PROXY': (int, 'General', 0),
'HTTP_ROOT': (str, 'General', '/'),
'HTTP_USERNAME': (str, 'General', ''),
'INTERFACE': (str, 'General', 'default'),
'JOURNAL_MODE': (str, 'Advanced', 'wal'),
'LAUNCH_BROWSER': (int, 'General', 1),
'LMS_ENABLED': (int, 'LMS', 0),
'LMS_HOST': (str, 'LMS', ''),
'LOG_DIR': (str, 'General', ''),
'MPC_ENABLED': (bool_int, 'MPC', False),
'NMA_APIKEY': (str, 'NMA', ''),
'NMA_ENABLED': (int, 'NMA', 0),
'NMA_PRIORITY': (int, 'NMA', 0),
'OSX_NOTIFY_APP': (str, 'OSX_Notify', '/Applications/PlexPy'),
'OSX_NOTIFY_ENABLED': (int, 'OSX_Notify', 0),
'PLEX_CLIENT_HOST': (str, 'Plex', ''),
'PLEX_ENABLED': (int, 'Plex', 0),
'PLEX_PASSWORD': (str, 'Plex', ''),
'PLEX_USERNAME': (str, 'Plex', ''),
'PROWL_ENABLED': (int, 'Prowl', 0),
'PROWL_KEYS': (str, 'Prowl', ''),
'PROWL_PRIORITY': (int, 'Prowl', 0),
'PUSHALOT_APIKEY': (str, 'Pushalot', ''),
'PUSHALOT_ENABLED': (int, 'Pushalot', 0),
'PUSHBULLET_APIKEY': (str, 'PushBullet', ''),
'PUSHBULLET_DEVICEID': (str, 'PushBullet', ''),
'PUSHBULLET_ENABLED': (int, 'PushBullet', 0),
'PUSHOVER_APITOKEN': (str, 'Pushover', ''),
'PUSHOVER_ENABLED': (int, 'Pushover', 0),
'PUSHOVER_KEYS': (str, 'Pushover', ''),
'PUSHOVER_PRIORITY': (int, 'Pushover', 0),
'SUBSONIC_ENABLED': (int, 'Subsonic', 0),
'SUBSONIC_HOST': (str, 'Subsonic', ''),
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
'SUBSONIC_USERNAME': (str, 'Subsonic', ''),
'SYNOINDEX_ENABLED': (int, 'Synoindex', 0),
'TWITTER_ENABLED': (int, 'Twitter', 0),
'TWITTER_PASSWORD': (str, 'Twitter', ''),
'TWITTER_PREFIX': (str, 'Twitter', 'Headphones'),
'TWITTER_USERNAME': (str, 'Twitter', ''),
'UPDATE_DB_INTERVAL': (int, 'General', 24),
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
'XBMC_ENABLED': (int, 'XBMC', 0),
'XBMC_HOST': (str, 'XBMC', ''),
'XBMC_PASSWORD': (str, 'XBMC', ''),
'XBMC_USERNAME': (str, 'XBMC', '')
}
# pylint:disable=R0902
# it might be nice to refactor for fewer instance variables
class Config(object):
""" Wraps access to particular values in a config file """
def __init__(self, config_file):
""" Initialize the config with values from a file """
self._config_file = config_file
self._config = ConfigObj(self._config_file, encoding='utf-8')
for key in _CONFIG_DEFINITIONS.keys():
self.check_setting(key)
def _define(self, name):
key = name.upper()
ini_key = name.lower()
definition = _CONFIG_DEFINITIONS[key]
if len(definition) == 3:
definition_type, section, default = definition
else:
definition_type, section, _, default = definition
return key, definition_type, section, ini_key, default
def check_section(self, section):
""" Check if INI section exists, if not create it """
if section not in self._config:
self._config[section] = {}
return True
else:
return False
def check_setting(self, key):
""" Cast any value in the config to the right type or use the default """
key, definition_type, section, ini_key, default = self._define(key)
self.check_section(section)
try:
my_val = definition_type(self._config[section][ini_key])
except Exception:
my_val = definition_type(default)
self._config[section][ini_key] = my_val
return my_val
def write(self):
""" Make a copy of the stored config and write it to the configured file """
new_config = ConfigObj(encoding="UTF-8")
new_config.filename = self._config_file
# first copy over everything from the old config, even if it is not
# correctly defined to keep from losing data
for key, subkeys in self._config.items():
if key not in new_config:
new_config[key] = {}
for subkey, value in subkeys.items():
new_config[key][subkey] = value
# next make sure that everything we expect to have defined is so
for key in _CONFIG_DEFINITIONS.keys():
key, definition_type, section, ini_key, default = self._define(key)
self.check_setting(key)
if section not in new_config:
new_config[section] = {}
new_config[section][ini_key] = self._config[section][ini_key]
# Write it to file
plexpy.logger.info("Writing configuration to file")
try:
new_config.write()
except IOError as e:
plexpy.logger.error("Error writing configuration file: %s", e)
def __getattr__(self, name):
"""
Returns something from the ini unless it is a real property
of the configuration object or is not all caps.
"""
if not re.match(r'[A-Z_]+$', name):
return super(Config, self).__getattr__(name)
else:
return self.check_setting(name)
def __setattr__(self, name, value):
"""
Maps all-caps properties to ini values unless they exist on the
configuration object.
"""
if not re.match(r'[A-Z_]+$', name):
super(Config, self).__setattr__(name, value)
return value
else:
key, definition_type, section, ini_key, default = self._define(name)
self._config[section][ini_key] = definition_type(value)
return self._config[section][ini_key]
def process_kwargs(self, kwargs):
"""
Given a big bunch of key value pairs, apply them to the ini.
"""
for name, value in kwargs.items():
key, definition_type, section, ini_key, default = self._define(name)
self._config[section][ini_key] = definition_type(value)

126
plexpy/db.py Normal file
View file

@ -0,0 +1,126 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
#####################################
## Stolen from Sick-Beard's db.py ##
#####################################
from __future__ import with_statement
import os
import sqlite3
import plexpy
from plexpy import logger
def dbFilename(filename):
return os.path.join(plexpy.DATA_DIR, filename)
def getCacheSize():
#this will protect against typecasting problems produced by empty string and None settings
if not plexpy.CONFIG.CACHE_SIZEMB:
#sqlite will work with this (very slowly)
return 0
return int(plexpy.CONFIG.CACHE_SIZEMB)
class DBConnection:
def __init__(self):
self.filename = plexpy.CONFIG.PLEXWATCH_DATABASE
#self.connection = sqlite3.connect(dbFilename(plexpy.CONFIG.PLEXWATCH_DATABASE), timeout=20)
self.connection = sqlite3.connect(plexpy.CONFIG.PLEXWATCH_DATABASE, timeout=20)
#don't wait for the disk to finish writing
self.connection.execute("PRAGMA synchronous = OFF")
#journal disabled since we never do rollbacks
self.connection.execute("PRAGMA journal_mode = %s" % plexpy.CONFIG.JOURNAL_MODE)
#64mb of cache memory,probably need to make it user configurable
self.connection.execute("PRAGMA cache_size=-%s" % (getCacheSize() * 1024))
self.connection.row_factory = sqlite3.Row
def action(self, query, args=None):
if query is None:
return
sqlResult = None
try:
with self.connection as c:
if args is None:
sqlResult = c.execute(query)
else:
sqlResult = c.execute(query, args)
except sqlite3.OperationalError, e:
if "unable to open database file" in e.message or "database is locked" in e.message:
logger.warn('Database Error: %s', e)
else:
logger.error('Database error: %s', e)
raise
except sqlite3.DatabaseError, e:
logger.error('Fatal Error executing %s :: %s', query, e)
raise
return sqlResult
def select(self, query, args=None):
sqlResults = self.action(query, args).fetchall()
if sqlResults is None or sqlResults == [None]:
return []
return sqlResults
def get_history_table_name(self):
if plexpy.CONFIG.GROUPING_GLOBAL_HISTORY:
return "grouped"
else:
return "processed"
def get_user_table_name(self):
if plexpy.CONFIG.GROUPING_USER_HISTORY:
return "grouped"
else:
return "processed"
def upsert(self, tableName, valueDict, keyDict):
changesBefore = self.connection.total_changes
genParams = lambda myDict: [x + " = ?" for x in myDict.keys()]
update_query = "UPDATE " + tableName + " SET " + ", ".join(genParams(valueDict)) + " WHERE " + " AND ".join(genParams(keyDict))
self.action(update_query, valueDict.values() + keyDict.values())
if self.connection.total_changes == changesBefore:
insert_query = (
"INSERT INTO " + tableName + " (" + ", ".join(valueDict.keys() + keyDict.keys()) + ")" +
" VALUES (" + ", ".join(["?"] * len(valueDict.keys() + keyDict.keys())) + ")"
)
try:
self.action(insert_query, valueDict.values() + keyDict.values())
except sqlite3.IntegrityError:
logger.info('Queries failed: %s and %s', update_query, insert_query)

21
plexpy/exceptions.py Normal file
View file

@ -0,0 +1,21 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
class PlexPyException(Exception):
"""
Generic PlexPy Exception - should never be thrown, only subclassed
"""

327
plexpy/helpers.py Normal file
View file

@ -0,0 +1,327 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
from operator import itemgetter
import unicodedata
import plexpy
import datetime
import fnmatch
import shutil
import time
import sys
import re
import os
def multikeysort(items, columns):
comparers = [((itemgetter(col[1:].strip()), -1) if col.startswith('-') else (itemgetter(col.strip()), 1)) for col in columns]
def comparer(left, right):
for fn, mult in comparers:
result = cmp(fn(left), fn(right))
if result:
return mult * result
else:
return 0
return sorted(items, cmp=comparer)
def checked(variable):
if variable:
return 'Checked'
else:
return ''
def radio(variable, pos):
if variable == pos:
return 'Checked'
else:
return ''
def latinToAscii(unicrap):
"""
From couch potato
"""
xlate = {
0xc0: 'A', 0xc1: 'A', 0xc2: 'A', 0xc3: 'A', 0xc4: 'A', 0xc5: 'A',
0xc6: 'Ae', 0xc7: 'C',
0xc8: 'E', 0xc9: 'E', 0xca: 'E', 0xcb: 'E', 0x86: 'e',
0xcc: 'I', 0xcd: 'I', 0xce: 'I', 0xcf: 'I',
0xd0: 'Th', 0xd1: 'N',
0xd2: 'O', 0xd3: 'O', 0xd4: 'O', 0xd5: 'O', 0xd6: 'O', 0xd8: 'O',
0xd9: 'U', 0xda: 'U', 0xdb: 'U', 0xdc: 'U',
0xdd: 'Y', 0xde: 'th', 0xdf: 'ss',
0xe0: 'a', 0xe1: 'a', 0xe2: 'a', 0xe3: 'a', 0xe4: 'a', 0xe5: 'a',
0xe6: 'ae', 0xe7: 'c',
0xe8: 'e', 0xe9: 'e', 0xea: 'e', 0xeb: 'e', 0x0259: 'e',
0xec: 'i', 0xed: 'i', 0xee: 'i', 0xef: 'i',
0xf0: 'th', 0xf1: 'n',
0xf2: 'o', 0xf3: 'o', 0xf4: 'o', 0xf5: 'o', 0xf6: 'o', 0xf8: 'o',
0xf9: 'u', 0xfa: 'u', 0xfb: 'u', 0xfc: 'u',
0xfd: 'y', 0xfe: 'th', 0xff: 'y',
0xa1: '!', 0xa2: '{cent}', 0xa3: '{pound}', 0xa4: '{currency}',
0xa5: '{yen}', 0xa6: '|', 0xa7: '{section}', 0xa8: '{umlaut}',
0xa9: '{C}', 0xaa: '{^a}', 0xab: '<<', 0xac: '{not}',
0xad: '-', 0xae: '{R}', 0xaf: '_', 0xb0: '{degrees}',
0xb1: '{+/-}', 0xb2: '{^2}', 0xb3: '{^3}', 0xb4: "'",
0xb5: '{micro}', 0xb6: '{paragraph}', 0xb7: '*', 0xb8: '{cedilla}',
0xb9: '{^1}', 0xba: '{^o}', 0xbb: '>>',
0xbc: '{1/4}', 0xbd: '{1/2}', 0xbe: '{3/4}', 0xbf: '?',
0xd7: '*', 0xf7: '/'
}
r = ''
for i in unicrap:
if ord(i) in xlate:
r += xlate[ord(i)]
elif ord(i) >= 0x80:
pass
else:
r += str(i)
return r
def convert_milliseconds(ms):
seconds = ms / 1000
gmtime = time.gmtime(seconds)
if seconds > 3600:
minutes = time.strftime("%H:%M:%S", gmtime)
else:
minutes = time.strftime("%M:%S", gmtime)
return minutes
def convert_seconds(s):
gmtime = time.gmtime(s)
if s > 3600:
minutes = time.strftime("%H:%M:%S", gmtime)
else:
minutes = time.strftime("%M:%S", gmtime)
return minutes
def today():
today = datetime.date.today()
yyyymmdd = datetime.date.isoformat(today)
return yyyymmdd
def now():
now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S")
def get_age(date):
try:
split_date = date.split('-')
except:
return False
try:
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
except IndexError:
days_old = False
return days_old
def bytes_to_mb(bytes):
mb = int(bytes) / 1048576
size = '%.1f MB' % mb
return size
def mb_to_bytes(mb_str):
result = re.search('^(\d+(?:\.\d+)?)\s?(?:mb)?', mb_str, flags=re.I)
if result:
return int(float(result.group(1)) * 1048576)
def piratesize(size):
split = size.split(" ")
factor = float(split[0])
unit = split[1].upper()
if unit == 'MiB':
size = factor * 1048576
elif unit == 'MB':
size = factor * 1000000
elif unit == 'GiB':
size = factor * 1073741824
elif unit == 'GB':
size = factor * 1000000000
elif unit == 'KiB':
size = factor * 1024
elif unit == 'KB':
size = factor * 1000
elif unit == "B":
size = factor
else:
size = 0
return size
def replace_all(text, dic, normalize=False):
if not text:
return ''
for i, j in dic.iteritems():
if normalize:
try:
if sys.platform == 'darwin':
j = unicodedata.normalize('NFD', j)
else:
j = unicodedata.normalize('NFC', j)
except TypeError:
j = unicodedata.normalize('NFC', j.decode(plexpy.SYS_ENCODING, 'replace'))
text = text.replace(i, j)
return text
def replace_illegal_chars(string, type="file"):
if type == "file":
string = re.sub('[\?"*:|<>/]', '_', string)
if type == "folder":
string = re.sub('[:\?<>"|]', '_', string)
return string
def cleanName(string):
pass1 = latinToAscii(string).lower()
out_string = re.sub('[\.\-\/\!\@\#\$\%\^\&\*\(\)\+\-\"\'\,\;\:\[\]\{\}\<\>\=\_]', '', pass1).encode('utf-8')
return out_string
def cleanTitle(title):
title = re.sub('[\.\-\/\_]', ' ', title).lower()
# Strip out extra whitespace
title = ' '.join(title.split())
title = title.title()
return title
def split_path(f):
"""
Split a path into components, starting with the drive letter (if any). Given
a path, os.path.join(*split_path(f)) should be path equal to f.
"""
components = []
drive, path = os.path.splitdrive(f)
# Strip the folder from the path, iterate until nothing is left
while True:
path, folder = os.path.split(path)
if folder:
components.append(folder)
else:
if path:
components.append(path)
break
# Append the drive (if any)
if drive:
components.append(drive)
# Reverse components
components.reverse()
# Done
return components
def extract_logline(s):
# Default log format
pattern = re.compile(r'(?P<timestamp>.*?)\s\-\s(?P<level>.*?)\s*\:\:\s(?P<thread>.*?)\s\:\s(?P<message>.*)', re.VERBOSE)
match = pattern.match(s)
if match:
timestamp = match.group("timestamp")
level = match.group("level")
thread = match.group("thread")
message = match.group("message")
return (timestamp, level, thread, message)
else:
return None
def split_string(mystring, splitvar=','):
mylist = []
for each_word in mystring.split(splitvar):
mylist.append(each_word.strip())
return mylist
def create_https_certificates(ssl_cert, ssl_key):
"""
Create a pair of self-signed HTTPS certificares and store in them in
'ssl_cert' and 'ssl_key'. Method assumes pyOpenSSL is installed.
This code is stolen from SickBeard (http://github.com/midgetspy/Sick-Beard).
"""
from plexpy import logger
from OpenSSL import crypto
from certgen import createKeyPair, createCertRequest, createCertificate, \
TYPE_RSA, serial
# Create the CA Certificate
cakey = createKeyPair(TYPE_RSA, 2048)
careq = createCertRequest(cakey, CN="Certificate Authority")
cacert = createCertificate(careq, (careq, cakey), serial, (0, 60 * 60 * 24 * 365 * 10)) # ten years
pkey = createKeyPair(TYPE_RSA, 2048)
req = createCertRequest(pkey, CN="PlexPy")
cert = createCertificate(req, (cacert, cakey), serial, (0, 60 * 60 * 24 * 365 * 10)) # ten years
# Save the key and certificate to disk
try:
with open(ssl_key, "w") as fp:
fp.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey))
with open(ssl_cert, "w") as fp:
fp.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
except IOError as e:
logger.error("Error creating SSL key and certificate: %s", e)
return False
return True
def cast_to_float(s):
try:
return float(s)
except ValueError:
return -1

87
plexpy/lock.py Normal file
View file

@ -0,0 +1,87 @@
"""
Locking-related classes
"""
import plexpy.logger
import time
import threading
import Queue
class TimedLock(object):
"""
Enforce request rate limit if applicable. This uses the lock so there
is synchronized access to the API. When N threads enter this method, the
first will pass trough, since there there was no last request recorded.
The last request time will be set. Then, the second thread will unlock,
and see that the last request was X seconds ago. It will sleep
(request_limit - X) seconds, and then continue. Then the third one will
unblock, and so on. After all threads finish, the total time will at
least be (N * request_limit) seconds. If some request takes longer than
request_limit seconds, the next unblocked thread will wait less.
"""
def __init__(self, minimum_delta=0):
"""
Set up the lock
"""
self.lock = threading.Lock()
self.last_used = 0
self.minimum_delta = minimum_delta
self.queue = Queue.Queue()
def __enter__(self):
"""
Called when with lock: is invoked
"""
self.lock.acquire()
delta = time.time() - self.last_used
sleep_amount = self.minimum_delta - delta
if sleep_amount >= 0:
# zero sleeps give the cpu a chance to task-switch
plexpy.logger.debug('Sleeping %s (interval)', sleep_amount)
time.sleep(sleep_amount)
while not self.queue.empty():
try:
seconds = self.queue.get(False)
plexpy.logger.debug('Sleeping %s (queued)', seconds)
time.sleep(seconds)
except Queue.Empty:
continue
self.queue.task_done()
def __exit__(self, type, value, traceback):
"""
Called when exiting the with block.
"""
self.last_used = time.time()
self.lock.release()
def snooze(self, seconds):
"""
Asynchronously add time to the next request. Can be called outside
of the lock context, but it is possible for the next lock holder
to not check the queue until after something adds time to it.
"""
# We use a queue so that we don't have to synchronize
# across threads and with or without locks
plexpy.logger.info('Adding %s to queue', seconds)
self.queue.put(seconds)
class FakeLock(object):
"""
If no locking or request throttling is needed, use this
"""
def __enter__(self):
"""
Do nothing on enter
"""
pass
def __exit__(self, type, value, traceback):
"""
Do nothing on exit
"""
pass

226
plexpy/logger.py Normal file
View file

@ -0,0 +1,226 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
from plexpy import helpers
from logutils.queue import QueueHandler, QueueListener
from logging import handlers
import multiprocessing
import contextlib
import plexpy
import threading
import traceback
import logging
import errno
import sys
import os
# These settings are for file logging only
FILENAME = "plexpy.log"
MAX_SIZE = 1000000 # 1 MB
MAX_FILES = 5
# PlexPy logger
logger = logging.getLogger("plexpy")
# Global queue for multiprocessing logging
queue = None
class LogListHandler(logging.Handler):
"""
Log handler for Web UI.
"""
def emit(self, record):
message = self.format(record)
message = message.replace("\n", "<br />")
plexpy.LOG_LIST.insert(0, (helpers.now(), message, record.levelname, record.threadName))
@contextlib.contextmanager
def listener():
"""
Wrapper that create a QueueListener, starts it and automatically stops it.
To be used in a with statement in the main process, for multiprocessing.
"""
global queue
# Initialize queue if not already done
if queue is None:
try:
queue = multiprocessing.Queue()
except OSError as e:
queue = False
# Some machines don't have access to /dev/shm. See
# http://stackoverflow.com/questions/2009278 for more information.
if e.errno == errno.EACCES:
logger.warning('Multiprocess logging disabled, because '
'current user cannot map shared memory. You won\'t see any' \
'logging generated by the worker processed.')
# Multiprocess logging may be disabled.
if not queue:
yield
else:
queue_listener = QueueListener(queue, *logger.handlers)
try:
queue_listener.start()
yield
finally:
queue_listener.stop()
def initMultiprocessing():
"""
Remove all handlers and add QueueHandler on top. This should only be called
inside a multiprocessing worker process, since it changes the logger
completely.
"""
# Multiprocess logging may be disabled.
if not queue:
return
# Remove all handlers and add the Queue handler as the only one.
for handler in logger.handlers[:]:
logger.removeHandler(handler)
queue_handler = QueueHandler(queue)
queue_handler.setLevel(logging.DEBUG)
logger.addHandler(queue_handler)
# Change current thread name for log record
threading.current_thread().name = multiprocessing.current_process().name
def initLogger(console=False, log_dir=False, verbose=False):
"""
Setup logging for PlexPy. It uses the logger instance with the name
'plexpy'. Three log handlers are added:
* RotatingFileHandler: for the file plexpy.log
* LogListHandler: for Web UI
* StreamHandler: for console (if console)
Console logging is only enabled if console is set to True. This method can
be invoked multiple times, during different stages of PlexPy.
"""
# Close and remove old handlers. This is required to reinit the loggers
# at runtime
for handler in logger.handlers[:]:
# Just make sure it is cleaned up.
if isinstance(handler, handlers.RotatingFileHandler):
handler.close()
elif isinstance(handler, logging.StreamHandler):
handler.flush()
logger.removeHandler(handler)
# Configure the logger to accept all messages
logger.propagate = False
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
# Add list logger
loglist_handler = LogListHandler()
loglist_handler.setLevel(logging.DEBUG)
logger.addHandler(loglist_handler)
# Setup file logger
if log_dir:
filename = os.path.join(log_dir, FILENAME)
file_formatter = logging.Formatter('%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# Setup console logger
if console:
console_formatter = logging.Formatter('%(asctime)s - %(levelname)s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
console_handler = logging.StreamHandler()
console_handler.setFormatter(console_formatter)
console_handler.setLevel(logging.DEBUG)
logger.addHandler(console_handler)
# Install exception hooks
initHooks()
def initHooks(global_exceptions=True, thread_exceptions=True, pass_original=True):
"""
This method installs exception catching mechanisms. Any exception caught
will pass through the exception hook, and will be logged to the logger as
an error. Additionally, a traceback is provided.
This is very useful for crashing threads and any other bugs, that may not
be exposed when running as daemon.
The default exception hook is still considered, if pass_original is True.
"""
def excepthook(*exception_info):
# We should always catch this to prevent loops!
try:
message = "".join(traceback.format_exception(*exception_info))
logger.error("Uncaught exception: %s", message)
except:
pass
# Original excepthook
if pass_original:
sys.__excepthook__(*exception_info)
# Global exception hook
if global_exceptions:
sys.excepthook = excepthook
# Thread exception hook
if thread_exceptions:
old_init = threading.Thread.__init__
def new_init(self, *args, **kwargs):
old_init(self, *args, **kwargs)
old_run = self.run
def new_run(*args, **kwargs):
try:
old_run(*args, **kwargs)
except (KeyboardInterrupt, SystemExit):
raise
except:
excepthook(*sys.exc_info())
self.run = new_run
# Monkey patch the run() by monkey patching the __init__ method
threading.Thread.__init__ = new_init
# Expose logger methods
info = logger.info
warn = logger.warn
error = logger.error
debug = logger.debug
warning = logger.warning
exception = logger.exception

862
plexpy/notifiers.py Normal file
View file

@ -0,0 +1,862 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
from plexpy import logger, helpers, common, request
from xml.dom import minidom
from httplib import HTTPSConnection
from urlparse import parse_qsl
from urllib import urlencode
from pynma import pynma
import base64
import cherrypy
import urllib
import urllib2
import plexpy
import os.path
import subprocess
import gntp.notifier
import json
import oauth2 as oauth
import pythontwitter as twitter
from email.mime.text import MIMEText
import smtplib
import email.utils
class GROWL(object):
"""
Growl notifications, for OS X.
"""
def __init__(self):
self.enabled = plexpy.CONFIG.GROWL_ENABLED
self.host = plexpy.CONFIG.GROWL_HOST
self.password = plexpy.CONFIG.GROWL_PASSWORD
def conf(self, options):
return cherrypy.config['config'].get('Growl', options)
def notify(self, message, event):
if not self.enabled:
return
# Split host and port
if self.host == "":
host, port = "localhost", 23053
if ":" in self.host:
host, port = self.host.split(':', 1)
port = int(port)
else:
host, port = self.host, 23053
# If password is empty, assume none
if self.password == "":
password = None
else:
password = self.password
# Register notification
growl = gntp.notifier.GrowlNotifier(
applicationName='PlexPy',
notifications=['New Event'],
defaultNotifications=['New Event'],
hostname=host,
port=port,
password=password
)
try:
growl.register()
except gntp.notifier.errors.NetworkError:
logger.warning(u'Growl notification failed: network error')
return
except gntp.notifier.errors.AuthError:
logger.warning(u'Growl notification failed: authentication error')
return
# Fix message
message = message.encode(plexpy.SYS_ENCODING, "replace")
# Send it, including an image
image_file = os.path.join(str(plexpy.PROG_DIR),
"data/images/plexpylogo.png")
with open(image_file, 'rb') as f:
image = f.read()
try:
growl.notify(
noteType='New Event',
title=event,
description=message,
icon=image
)
except gntp.notifier.errors.NetworkError:
logger.warning(u'Growl notification failed: network error')
return
logger.info(u"Growl notifications sent.")
def updateLibrary(self):
#For uniformity reasons not removed
return
def test(self, host, password):
self.enabled = True
self.host = host
self.password = password
self.notify('ZOMG Lazors Pewpewpew!', 'Test Message')
class PROWL(object):
"""
Prowl notifications.
"""
def __init__(self):
self.enabled = plexpy.CONFIG.PROWL_ENABLED
self.keys = plexpy.CONFIG.PROWL_KEYS
self.priority = plexpy.CONFIG.PROWL_PRIORITY
def conf(self, options):
return cherrypy.config['config'].get('Prowl', options)
def notify(self, message, event):
if not plexpy.CONFIG.PROWL_ENABLED:
return
http_handler = HTTPSConnection("api.prowlapp.com")
data = {'apikey': plexpy.CONFIG.PROWL_KEYS,
'application': 'PlexPy',
'event': event,
'description': message.encode("utf-8"),
'priority': plexpy.CONFIG.PROWL_PRIORITY}
http_handler.request("POST",
"/publicapi/add",
headers={'Content-type': "application/x-www-form-urlencoded"},
body=urlencode(data))
response = http_handler.getresponse()
request_status = response.status
if request_status == 200:
logger.info(u"Prowl notifications sent.")
return True
elif request_status == 401:
logger.info(u"Prowl auth failed: %s" % response.reason)
return False
else:
logger.info(u"Prowl notification failed.")
return False
def updateLibrary(self):
#For uniformity reasons not removed
return
def test(self, keys, priority):
self.enabled = True
self.keys = keys
self.priority = priority
self.notify('ZOMG Lazors Pewpewpew!', 'Test Message')
class MPC(object):
"""
MPC library update
"""
def __init__(self):
pass
def notify(self):
subprocess.call(["mpc", "update"])
class XBMC(object):
"""
XBMC notifications
"""
def __init__(self):
self.hosts = plexpy.CONFIG.XBMC_HOST
self.username = plexpy.CONFIG.XBMC_USERNAME
self.password = plexpy.CONFIG.XBMC_PASSWORD
def _sendhttp(self, host, command):
url_command = urllib.urlencode(command)
url = host + '/xbmcCmds/xbmcHttp/?' + url_command
if self.password:
return request.request_content(url, auth=(self.username, self.password))
else:
return request.request_content(url)
def _sendjson(self, host, method, params={}):
data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
headers = {'Content-Type': 'application/json'}
url = host + '/jsonrpc'
if self.password:
response = request.request_json(url, method="post", data=json.dumps(data), headers=headers, auth=(self.username, self.password))
else:
response = request.request_json(url, method="post", data=json.dumps(data), headers=headers)
if response:
return response[0]['result']
def update(self):
# From what I read you can't update the music library on a per directory or per path basis
# so need to update the whole thing
hosts = [x.strip() for x in self.hosts.split(',')]
for host in hosts:
logger.info('Sending library update command to XBMC @ ' + host)
request = self._sendjson(host, 'AudioLibrary.Scan')
if not request:
logger.warn('Error sending update request to XBMC')
def notify(self, artist, album, albumartpath):
hosts = [x.strip() for x in self.hosts.split(',')]
header = "PlexPy"
message = "%s - %s added to your library" % (artist, album)
time = "3000" # in ms
for host in hosts:
logger.info('Sending notification command to XMBC @ ' + host)
try:
version = self._sendjson(host, 'Application.GetProperties', {'properties': ['version']})['version']['major']
if version < 12: #Eden
notification = header + "," + message + "," + time + "," + albumartpath
notifycommand = {'command': 'ExecBuiltIn', 'parameter': 'Notification(' + notification + ')'}
request = self._sendhttp(host, notifycommand)
else: #Frodo
params = {'title': header, 'message': message, 'displaytime': int(time), 'image': albumartpath}
request = self._sendjson(host, 'GUI.ShowNotification', params)
if not request:
raise Exception
except Exception:
logger.error('Error sending notification request to XBMC')
class LMS(object):
"""
Class for updating a Logitech Media Server
"""
def __init__(self):
self.hosts = plexpy.CONFIG.LMS_HOST
def _sendjson(self, host):
data = {'id': 1, 'method': 'slim.request', 'params': ["", ["rescan"]]}
data = json.JSONEncoder().encode(data)
content = {'Content-Type': 'application/json'}
req = urllib2.Request(host + '/jsonrpc.js', data, content)
try:
handle = urllib2.urlopen(req)
except Exception as e:
logger.warn('Error opening LMS url: %s' % e)
return
response = json.JSONDecoder().decode(handle.read())
try:
return response['result']
except:
logger.warn('LMS returned error: %s' % response['error'])
return response['error']
def update(self):
hosts = [x.strip() for x in self.hosts.split(',')]
for host in hosts:
logger.info('Sending library rescan command to LMS @ ' + host)
request = self._sendjson(host)
if request:
logger.warn('Error sending rescan request to LMS')
class Plex(object):
def __init__(self):
self.server_hosts = plexpy.CONFIG.PLEX_SERVER_HOST
self.client_hosts = plexpy.CONFIG.PLEX_CLIENT_HOST
self.username = plexpy.CONFIG.PLEX_USERNAME
self.password = plexpy.CONFIG.PLEX_PASSWORD
def _sendhttp(self, host, command):
username = self.username
password = self.password
url_command = urllib.urlencode(command)
url = host + '/xbmcCmds/xbmcHttp/?' + url_command
req = urllib2.Request(url)
if password:
base64string = base64.encodestring('%s:%s' % (username, password)).replace('\n', '')
req.add_header("Authorization", "Basic %s" % base64string)
logger.info('Plex url: %s' % url)
try:
handle = urllib2.urlopen(req)
except Exception as e:
logger.warn('Error opening Plex url: %s' % e)
return
response = handle.read().decode(plexpy.SYS_ENCODING)
return response
def update(self):
# From what I read you can't update the music library on a per directory or per path basis
# so need to update the whole thing
hosts = [x.strip() for x in self.server_hosts.split(',')]
for host in hosts:
logger.info('Sending library update command to Plex Media Server@ ' + host)
url = "%s/library/sections" % host
try:
xml_sections = minidom.parse(urllib.urlopen(url))
except IOError, e:
logger.warn("Error while trying to contact Plex Media Server: %s" % e)
return False
sections = xml_sections.getElementsByTagName('Directory')
if not sections:
logger.info(u"Plex Media Server not running on: " + host)
return False
for s in sections:
if s.getAttribute('type') == "artist":
url = "%s/library/sections/%s/refresh" % (host, s.getAttribute('key'))
try:
urllib.urlopen(url)
except Exception as e:
logger.warn("Error updating library section for Plex Media Server: %s" % e)
return False
def notify(self, artist, album, albumartpath):
hosts = [x.strip() for x in self.client_hosts.split(',')]
header = "PlexPy"
message = "%s - %s added to your library" % (artist, album)
time = "3000" # in ms
for host in hosts:
logger.info('Sending notification command to Plex Media Server @ ' + host)
try:
notification = header + "," + message + "," + time + "," + albumartpath
notifycommand = {'command': 'ExecBuiltIn', 'parameter': 'Notification(' + notification + ')'}
request = self._sendhttp(host, notifycommand)
if not request:
raise Exception
except:
logger.warn('Error sending notification request to Plex Media Server')
class NMA(object):
def notify(self, artist=None, album=None, snatched=None):
title = 'PlexPy'
api = plexpy.CONFIG.NMA_APIKEY
nma_priority = plexpy.CONFIG.NMA_PRIORITY
logger.debug(u"NMA title: " + title)
logger.debug(u"NMA API: " + api)
logger.debug(u"NMA Priority: " + str(nma_priority))
if snatched:
event = snatched + " snatched!"
message = "PlexPy has snatched: " + snatched
else:
event = artist + ' - ' + album + ' complete!'
message = "PlexPy has downloaded and postprocessed: " + artist + ' [' + album + ']'
logger.debug(u"NMA event: " + event)
logger.debug(u"NMA message: " + message)
batch = False
p = pynma.PyNMA()
keys = api.split(',')
p.addkey(keys)
if len(keys) > 1:
batch = True
response = p.push(title, event, message, priority=nma_priority, batch_mode=batch)
if not response[api][u'code'] == u'200':
logger.error(u'Could not send notification to NotifyMyAndroid')
return False
else:
return True
class PUSHBULLET(object):
def __init__(self):
self.apikey = plexpy.CONFIG.PUSHBULLET_APIKEY
self.deviceid = plexpy.CONFIG.PUSHBULLET_DEVICEID
def conf(self, options):
return cherrypy.config['config'].get('PUSHBULLET', options)
def notify(self, message, event):
if not plexpy.CONFIG.PUSHBULLET_ENABLED:
return
http_handler = HTTPSConnection("api.pushbullet.com")
data = {'type': "note",
'title': "PlexPy",
'body': message.encode("utf-8")}
http_handler.request("POST",
"/v2/pushes",
headers={'Content-type': "application/json",
'Authorization': 'Basic %s' % base64.b64encode(plexpy.CONFIG.PUSHBULLET_APIKEY + ":")},
body=json.dumps(data))
response = http_handler.getresponse()
request_status = response.status
logger.debug(u"PushBullet response status: %r" % request_status)
logger.debug(u"PushBullet response headers: %r" % response.getheaders())
logger.debug(u"PushBullet response body: %r" % response.read())
if request_status == 200:
logger.info(u"PushBullet notifications sent.")
return True
elif request_status >= 400 and request_status < 500:
logger.info(u"PushBullet request failed: %s" % response.reason)
return False
else:
logger.info(u"PushBullet notification failed serverside.")
return False
def updateLibrary(self):
#For uniformity reasons not removed
return
def test(self, apikey, deviceid):
self.enabled = True
self.apikey = apikey
self.deviceid = deviceid
self.notify('Main Screen Activate', 'Test Message')
class PUSHALOT(object):
def notify(self, message, event):
if not plexpy.CONFIG.PUSHALOT_ENABLED:
return
pushalot_authorizationtoken = plexpy.CONFIG.PUSHALOT_APIKEY
logger.debug(u"Pushalot event: " + event)
logger.debug(u"Pushalot message: " + message)
logger.debug(u"Pushalot api: " + pushalot_authorizationtoken)
http_handler = HTTPSConnection("pushalot.com")
data = {'AuthorizationToken': pushalot_authorizationtoken,
'Title': event.encode('utf-8'),
'Body': message.encode("utf-8")}
http_handler.request("POST",
"/api/sendmessage",
headers={'Content-type': "application/x-www-form-urlencoded"},
body=urlencode(data))
response = http_handler.getresponse()
request_status = response.status
logger.debug(u"Pushalot response status: %r" % request_status)
logger.debug(u"Pushalot response headers: %r" % response.getheaders())
logger.debug(u"Pushalot response body: %r" % response.read())
if request_status == 200:
logger.info(u"Pushalot notifications sent.")
return True
elif request_status == 410:
logger.info(u"Pushalot auth failed: %s" % response.reason)
return False
else:
logger.info(u"Pushalot notification failed.")
return False
class Synoindex(object):
def __init__(self, util_loc='/usr/syno/bin/synoindex'):
self.util_loc = util_loc
def util_exists(self):
return os.path.exists(self.util_loc)
def notify(self, path):
path = os.path.abspath(path)
if not self.util_exists():
logger.warn("Error sending notification: synoindex utility not found at %s" % self.util_loc)
return
if os.path.isfile(path):
cmd_arg = '-a'
elif os.path.isdir(path):
cmd_arg = '-A'
else:
logger.warn("Error sending notification: Path passed to synoindex was not a file or folder.")
return
cmd = [self.util_loc, cmd_arg, path]
logger.info("Calling synoindex command: %s" % str(cmd))
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=plexpy.PROG_DIR)
out, error = p.communicate()
#synoindex never returns any codes other than '0', highly irritating
except OSError, e:
logger.warn("Error sending notification: %s" % str(e))
def notify_multiple(self, path_list):
if isinstance(path_list, list):
for path in path_list:
self.notify(path)
class PUSHOVER(object):
def __init__(self):
self.enabled = plexpy.CONFIG.PUSHOVER_ENABLED
self.keys = plexpy.CONFIG.PUSHOVER_KEYS
self.priority = plexpy.CONFIG.PUSHOVER_PRIORITY
if plexpy.CONFIG.PUSHOVER_APITOKEN:
self.application_token = plexpy.CONFIG.PUSHOVER_APITOKEN
else:
self.application_token = "LdPCoy0dqC21ktsbEyAVCcwvQiVlsz"
def conf(self, options):
return cherrypy.config['config'].get('Pushover', options)
def notify(self, message, event):
if not plexpy.CONFIG.PUSHOVER_ENABLED:
return
http_handler = HTTPSConnection("api.pushover.net")
data = {'token': self.application_token,
'user': plexpy.CONFIG.PUSHOVER_KEYS,
'title': event,
'message': message.encode("utf-8"),
'priority': plexpy.CONFIG.PUSHOVER_PRIORITY}
http_handler.request("POST",
"/1/messages.json",
headers={'Content-type': "application/x-www-form-urlencoded"},
body=urlencode(data))
response = http_handler.getresponse()
request_status = response.status
logger.debug(u"Pushover response status: %r" % request_status)
logger.debug(u"Pushover response headers: %r" % response.getheaders())
logger.debug(u"Pushover response body: %r" % response.read())
if request_status == 200:
logger.info(u"Pushover notifications sent.")
return True
elif request_status >= 400 and request_status < 500:
logger.info(u"Pushover request failed: %s" % response.reason)
return False
else:
logger.info(u"Pushover notification failed.")
return False
def updateLibrary(self):
#For uniformity reasons not removed
return
def test(self, keys, priority):
self.enabled = True
self.keys = keys
self.priority = priority
self.notify('Main Screen Activate', 'Test Message')
class TwitterNotifier(object):
REQUEST_TOKEN_URL = 'https://api.twitter.com/oauth/request_token'
ACCESS_TOKEN_URL = 'https://api.twitter.com/oauth/access_token'
AUTHORIZATION_URL = 'https://api.twitter.com/oauth/authorize'
SIGNIN_URL = 'https://api.twitter.com/oauth/authenticate'
def __init__(self):
self.consumer_key = "oYKnp2ddX5gbARjqX8ZAAg"
self.consumer_secret = "A4Xkw9i5SjHbTk7XT8zzOPqivhj9MmRDR9Qn95YA9sk"
def notify_snatch(self, title):
if plexpy.CONFIG.TWITTER_ONSNATCH:
self._notifyTwitter(common.notifyStrings[common.NOTIFY_SNATCH] + ': ' + title + ' at ' + helpers.now())
def notify_download(self, title):
if plexpy.CONFIG.TWITTER_ENABLED:
self._notifyTwitter(common.notifyStrings[common.NOTIFY_DOWNLOAD] + ': ' + title + ' at ' + helpers.now())
def test_notify(self):
return self._notifyTwitter("This is a test notification from PlexPy at " + helpers.now(), force=True)
def _get_authorization(self):
oauth_consumer = oauth.Consumer(key=self.consumer_key, secret=self.consumer_secret)
oauth_client = oauth.Client(oauth_consumer)
logger.info('Requesting temp token from Twitter')
resp, content = oauth_client.request(self.REQUEST_TOKEN_URL, 'GET')
if resp['status'] != '200':
logger.info('Invalid respond from Twitter requesting temp token: %s' % resp['status'])
else:
request_token = dict(parse_qsl(content))
plexpy.CONFIG.TWITTER_USERNAME = request_token['oauth_token']
plexpy.CONFIG.TWITTER_PASSWORD = request_token['oauth_token_secret']
return self.AUTHORIZATION_URL + "?oauth_token=" + request_token['oauth_token']
def _get_credentials(self, key):
request_token = {}
request_token['oauth_token'] = plexpy.CONFIG.TWITTER_USERNAME
request_token['oauth_token_secret'] = plexpy.CONFIG.TWITTER_PASSWORD
request_token['oauth_callback_confirmed'] = 'true'
token = oauth.Token(request_token['oauth_token'], request_token['oauth_token_secret'])
token.set_verifier(key)
logger.info('Generating and signing request for an access token using key ' + key)
oauth_consumer = oauth.Consumer(key=self.consumer_key, secret=self.consumer_secret)
logger.info('oauth_consumer: ' + str(oauth_consumer))
oauth_client = oauth.Client(oauth_consumer, token)
logger.info('oauth_client: ' + str(oauth_client))
resp, content = oauth_client.request(self.ACCESS_TOKEN_URL, method='POST', body='oauth_verifier=%s' % key)
logger.info('resp, content: ' + str(resp) + ',' + str(content))
access_token = dict(parse_qsl(content))
logger.info('access_token: ' + str(access_token))
logger.info('resp[status] = ' + str(resp['status']))
if resp['status'] != '200':
logger.info('The request for a token with did not succeed: ' + str(resp['status']), logger.ERROR)
return False
else:
logger.info('Your Twitter Access Token key: %s' % access_token['oauth_token'])
logger.info('Access Token secret: %s' % access_token['oauth_token_secret'])
plexpy.CONFIG.TWITTER_USERNAME = access_token['oauth_token']
plexpy.CONFIG.TWITTER_PASSWORD = access_token['oauth_token_secret']
return True
def _send_tweet(self, message=None):
username = self.consumer_key
password = self.consumer_secret
access_token_key = plexpy.CONFIG.TWITTER_USERNAME
access_token_secret = plexpy.CONFIG.TWITTER_PASSWORD
logger.info(u"Sending tweet: " + message)
api = twitter.Api(username, password, access_token_key, access_token_secret)
try:
api.PostUpdate(message)
except Exception as e:
logger.info(u"Error Sending Tweet: %s" % e)
return False
return True
def _notifyTwitter(self, message='', force=False):
prefix = plexpy.CONFIG.TWITTER_PREFIX
if not plexpy.CONFIG.TWITTER_ENABLED and not force:
return False
return self._send_tweet(prefix + ": " + message)
class OSX_NOTIFY(object):
def __init__(self):
try:
self.objc = __import__("objc")
self.AppKit = __import__("AppKit")
except:
return False
def swizzle(self, cls, SEL, func):
old_IMP = cls.instanceMethodForSelector_(SEL)
def wrapper(self, *args, **kwargs):
return func(self, old_IMP, *args, **kwargs)
new_IMP = self.objc.selector(wrapper, selector=old_IMP.selector,
signature=old_IMP.signature)
self.objc.classAddMethod(cls, SEL, new_IMP)
def notify(self, title, subtitle=None, text=None, sound=True, image=None):
try:
self.swizzle(self.objc.lookUpClass('NSBundle'),
b'bundleIdentifier',
self.swizzled_bundleIdentifier)
NSUserNotification = self.objc.lookUpClass('NSUserNotification')
NSUserNotificationCenter = self.objc.lookUpClass('NSUserNotificationCenter')
NSAutoreleasePool = self.objc.lookUpClass('NSAutoreleasePool')
if not NSUserNotification or not NSUserNotificationCenter:
return False
pool = NSAutoreleasePool.alloc().init()
notification = NSUserNotification.alloc().init()
notification.setTitle_(title)
if subtitle:
notification.setSubtitle_(subtitle)
if text:
notification.setInformativeText_(text)
if sound:
notification.setSoundName_("NSUserNotificationDefaultSoundName")
if image:
source_img = self.AppKit.NSImage.alloc().initByReferencingFile_(image)
notification.setContentImage_(source_img)
#notification.set_identityImage_(source_img)
notification.setHasActionButton_(False)
notification_center = NSUserNotificationCenter.defaultUserNotificationCenter()
notification_center.deliverNotification_(notification)
del pool
return True
except Exception as e:
logger.warn('Error sending OS X Notification: %s' % e)
return False
def swizzled_bundleIdentifier(self, original, swizzled):
return 'ade.plexpy.osxnotify'
class BOXCAR(object):
def __init__(self):
self.url = 'https://new.boxcar.io/api/notifications'
def notify(self, title, message, rgid=None):
try:
if rgid:
message += '<br></br><a href="http://musicbrainz.org/release-group/%s">MusicBrainz</a>' % rgid
data = urllib.urlencode({
'user_credentials': plexpy.CONFIG.BOXCAR_TOKEN,
'notification[title]': title.encode('utf-8'),
'notification[long_message]': message.encode('utf-8'),
'notification[sound]': "done"
})
req = urllib2.Request(self.url)
handle = urllib2.urlopen(req, data)
handle.close()
return True
except urllib2.URLError as e:
logger.warn('Error sending Boxcar2 Notification: %s' % e)
return False
class SubSonicNotifier(object):
def __init__(self):
self.host = plexpy.CONFIG.SUBSONIC_HOST
self.username = plexpy.CONFIG.SUBSONIC_USERNAME
self.password = plexpy.CONFIG.SUBSONIC_PASSWORD
def notify(self, albumpaths):
# Correct URL
if not self.host.lower().startswith("http"):
self.host = "http://" + self.host
if not self.host.lower().endswith("/"):
self.host = self.host + "/"
# Invoke request
request.request_response(self.host + "musicFolderSettings.view?scanNow",
auth=(self.username, self.password))
class Email(object):
def notify(self, subject, message):
message = MIMEText(message, 'plain', "utf-8")
message['Subject'] = subject
message['From'] = email.utils.formataddr(('PlexPy', plexpy.CONFIG.EMAIL_FROM))
message['To'] = plexpy.CONFIG.EMAIL_TO
try:
mailserver = smtplib.SMTP(plexpy.CONFIG.EMAIL_SMTP_SERVER, plexpy.CONFIG.EMAIL_SMTP_PORT)
if (plexpy.CONFIG.EMAIL_TLS):
mailserver.starttls()
mailserver.ehlo()
if plexpy.CONFIG.EMAIL_SMTP_USER:
mailserver.login(plexpy.CONFIG.EMAIL_SMTP_USER, plexpy.CONFIG.EMAIL_SMTP_PASSWORD)
mailserver.sendmail(plexpy.CONFIG.EMAIL_FROM, plexpy.CONFIG.EMAIL_TO, message.as_string())
mailserver.quit()
return True
except Exception, e:
logger.warn('Error sending Email: %s' % e)
return False

237
plexpy/request.py Normal file
View file

@ -0,0 +1,237 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
from plexpy import logger
from xml.dom import minidom
from bs4 import BeautifulSoup
import requests
import feedparser
import plexpy
import plexpy.lock
import collections
# Dictionary with last request times, for rate limiting.
last_requests = collections.defaultdict(int)
fake_lock = plexpy.lock.FakeLock()
def request_response(url, method="get", auto_raise=True,
whitelist_status_code=None, lock=fake_lock, **kwargs):
"""
Convenient wrapper for `requests.get', which will capture the exceptions
and log them. On success, the Response object is returned. In case of a
exception, None is returned.
Additionally, there is support for rate limiting. To use this feature,
supply a tuple of (lock, request_limit). The lock is used to make sure no
other request with the same lock is executed. The request limit is the
minimal time between two requests (and so 1/request_limit is the number of
requests per seconds).
"""
# Convert whitelist_status_code to a list if needed
if whitelist_status_code and type(whitelist_status_code) != list:
whitelist_status_code = [whitelist_status_code]
# Disable verification of SSL certificates if requested. Note: this could
# pose a security issue!
kwargs["verify"] = bool(plexpy.CONFIG.VERIFY_SSL_CERT)
# Map method to the request.XXX method. This is a simple hack, but it
# allows requests to apply more magic per method. See lib/requests/api.py.
request_method = getattr(requests, method.lower())
try:
# Request URL and wait for response
with lock:
logger.debug(
"Requesting URL via %s method: %s", method.upper(), url)
response = request_method(url, **kwargs)
# If status code != OK, then raise exception, except if the status code
# is white listed.
if whitelist_status_code and auto_raise:
if response.status_code not in whitelist_status_code:
try:
response.raise_for_status()
except:
logger.debug(
"Response status code %d is not white "
"listed, raised exception", response.status_code)
raise
elif auto_raise:
response.raise_for_status()
return response
except requests.exceptions.SSLError as e:
if kwargs["verify"]:
logger.error(
"Unable to connect to remote host because of a SSL error. "
"It is likely that your system cannot verify the validity"
"of the certificate. The remote certificate is either "
"self-signed, or the remote server uses SNI. See the wiki for "
"more information on this topic.")
else:
logger.error(
"SSL error raised during connection, with certificate "
"verification turned off: %s", e)
except requests.ConnectionError:
logger.error(
"Unable to connect to remote host. Check if the remote "
"host is up and running.")
except requests.Timeout:
logger.error(
"Request timed out. The remote host did not respond timely.")
except requests.HTTPError as e:
if e.response is not None:
if e.response.status_code >= 500:
cause = "remote server error"
elif e.response.status_code >= 400:
cause = "local client error"
else:
# I don't think we will end up here, but for completeness
cause = "unknown"
logger.error(
"Request raise HTTP error with status code %d (%s).",
e.response.status_code, cause)
# Debug response
if plexpy.VERBOSE:
server_message(e.response)
else:
logger.error("Request raised HTTP error.")
except requests.RequestException as e:
logger.error("Request raised exception: %s", e)
def request_soup(url, **kwargs):
"""
Wrapper for `request_response', which will return a BeatifulSoup object if
no exceptions are raised.
"""
parser = kwargs.pop("parser", "html5lib")
response = request_response(url, **kwargs)
if response is not None:
return BeautifulSoup(response.content, parser)
def request_minidom(url, **kwargs):
"""
Wrapper for `request_response', which will return a Minidom object if no
exceptions are raised.
"""
response = request_response(url, **kwargs)
if response is not None:
return minidom.parseString(response.content)
def request_json(url, **kwargs):
"""
Wrapper for `request_response', which will decode the response as JSON
object and return the result, if no exceptions are raised.
As an option, a validator callback can be given, which should return True
if the result is valid.
"""
validator = kwargs.pop("validator", None)
response = request_response(url, **kwargs)
if response is not None:
try:
result = response.json()
if validator and not validator(result):
logger.error("JSON validation result failed")
else:
return result
except ValueError:
logger.error("Response returned invalid JSON data")
# Debug response
if plexpy.VERBOSE:
server_message(response)
def request_content(url, **kwargs):
"""
Wrapper for `request_response', which will return the raw content.
"""
response = request_response(url, **kwargs)
if response is not None:
return response.content
def request_feed(url, **kwargs):
"""
Wrapper for `request_response', which will return a feed object.
"""
response = request_response(url, **kwargs)
if response is not None:
return feedparser.parse(response.content)
def server_message(response):
"""
Extract server message from response and log in to logger with DEBUG level.
Some servers return extra information in the result. Try to parse it for
debugging purpose. Messages are limited to 150 characters, since it may
return the whole page in case of normal web page URLs
"""
message = None
# First attempt is to 'read' the response as HTML
if "text/html" in response.headers.get("content-type"):
try:
soup = BeautifulSoup(response.content, "html5lib")
except Exception:
pass
# Find body and cleanup common tags to grab content, which probably
# contains the message.
message = soup.find("body")
elements = ("header", "script", "footer", "nav", "input", "textarea")
for element in elements:
for tag in soup.find_all(element):
tag.replaceWith("")
message = message.text if message else soup.text
message = message.strip()
# Second attempt is to just take the response
if message is None:
message = response.content.strip()
if message:
# Truncate message if it is too long.
if len(message) > 150:
message = message[:150] + "..."
logger.debug("Server responded with message: %s", message)

1
plexpy/version.py Normal file
View file

@ -0,0 +1 @@
PLEXPY_VERSION = "master"

243
plexpy/versioncheck.py Normal file
View file

@ -0,0 +1,243 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
import re
import os
import tarfile
import platform
import plexpy
import subprocess
from plexpy import logger, version, request
def runGit(args):
if plexpy.CONFIG.GIT_PATH:
git_locations = ['"' + plexpy.CONFIG.GIT_PATH + '"']
else:
git_locations = ['git']
if platform.system().lower() == 'darwin':
git_locations.append('/usr/local/git/bin/git')
output = err = None
for cur_git in git_locations:
cmd = cur_git + ' ' + args
try:
logger.debug('Trying to execute: "' + cmd + '" with shell in ' + plexpy.PROG_DIR)
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=plexpy.PROG_DIR)
output, err = p.communicate()
output = output.strip()
logger.debug('Git output: ' + output)
except OSError:
logger.debug('Command failed: %s', cmd)
continue
if 'not found' in output or "not recognized as an internal or external command" in output:
logger.debug('Unable to find git with command ' + cmd)
output = None
elif 'fatal:' in output or err:
logger.error('Git returned bad info. Are you sure this is a git installation?')
output = None
elif output:
break
return (output, err)
def getVersion():
if version.PLEXPY_VERSION.startswith('win32build'):
plexpy.INSTALL_TYPE = 'win'
# Don't have a way to update exe yet, but don't want to set VERSION to None
return 'Windows Install', 'master'
elif os.path.isdir(os.path.join(plexpy.PROG_DIR, '.git')):
plexpy.INSTALL_TYPE = 'git'
output, err = runGit('rev-parse HEAD')
if not output:
logger.error('Couldn\'t find latest installed version.')
cur_commit_hash = None
cur_commit_hash = str(output)
if not re.match('^[a-z0-9]+$', cur_commit_hash):
logger.error('Output doesn\'t look like a hash, not using it')
cur_commit_hash = None
if plexpy.CONFIG.DO_NOT_OVERRIDE_GIT_BRANCH and plexpy.CONFIG.GIT_BRANCH:
branch_name = plexpy.CONFIG.GIT_BRANCH
else:
branch_name, err = runGit('rev-parse --abbrev-ref HEAD')
branch_name = branch_name
if not branch_name and plexpy.CONFIG.GIT_BRANCH:
logger.error('Could not retrieve branch name from git. Falling back to %s' % plexpy.CONFIG.GIT_BRANCH)
branch_name = plexpy.CONFIG.GIT_BRANCH
if not branch_name:
logger.error('Could not retrieve branch name from git. Defaulting to master')
branch_name = 'master'
return cur_commit_hash, branch_name
else:
plexpy.INSTALL_TYPE = 'source'
version_file = os.path.join(plexpy.PROG_DIR, 'version.txt')
if not os.path.isfile(version_file):
return None, 'master'
with open(version_file, 'r') as f:
current_version = f.read().strip(' \n\r')
if current_version:
return current_version, plexpy.CONFIG.GIT_BRANCH
else:
return None, 'master'
def checkGithub():
plexpy.COMMITS_BEHIND = 0
# Get the latest version available from github
logger.info('Retrieving latest version information from GitHub')
url = 'https://api.github.com/repos/%s/plexpy/commits/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_BRANCH)
version = request.request_json(url, timeout=20, validator=lambda x: type(x) == dict)
if version is None:
logger.warn('Could not get the latest version from GitHub. Are you running a local development version?')
return plexpy.CURRENT_VERSION
plexpy.LATEST_VERSION = version['sha']
logger.debug("Latest version is %s", plexpy.LATEST_VERSION)
# See how many commits behind we are
if not plexpy.CURRENT_VERSION:
logger.info('You are running an unknown version of PlexPy. Run the updater to identify your version')
return plexpy.LATEST_VERSION
if plexpy.LATEST_VERSION == plexpy.CURRENT_VERSION:
logger.info('PlexPy is up to date')
return plexpy.LATEST_VERSION
logger.info('Comparing currently installed version with latest GitHub version')
url = 'https://api.github.com/repos/%s/plexpy/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.LATEST_VERSION, plexpy.CURRENT_VERSION)
commits = request.request_json(url, timeout=20, whitelist_status_code=404, validator=lambda x: type(x) == dict)
if commits is None:
logger.warn('Could not get commits behind from GitHub.')
return plexpy.LATEST_VERSION
try:
plexpy.COMMITS_BEHIND = int(commits['behind_by'])
logger.debug("In total, %d commits behind", plexpy.COMMITS_BEHIND)
except KeyError:
logger.info('Cannot compare versions. Are you running a local development version?')
plexpy.COMMITS_BEHIND = 0
if plexpy.COMMITS_BEHIND > 0:
logger.info('New version is available. You are %s commits behind' % plexpy.COMMITS_BEHIND)
elif plexpy.COMMITS_BEHIND == 0:
logger.info('PlexPy is up to date')
return plexpy.LATEST_VERSION
def update():
if plexpy.INSTALL_TYPE == 'win':
logger.info('Windows .exe updating not supported yet.')
elif plexpy.INSTALL_TYPE == 'git':
output, err = runGit('pull origin ' + plexpy.CONFIG.GIT_BRANCH)
if not output:
logger.error('Couldn\'t download latest version')
for line in output.split('\n'):
if 'Already up-to-date.' in line:
logger.info('No update available, not updating')
logger.info('Output: ' + str(output))
elif line.endswith('Aborting.'):
logger.error('Unable to update from git: ' + line)
logger.info('Output: ' + str(output))
else:
tar_download_url = 'https://github.com/%s/plexpy/tarball/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_BRANCH)
update_dir = os.path.join(plexpy.PROG_DIR, 'update')
version_path = os.path.join(plexpy.PROG_DIR, 'version.txt')
logger.info('Downloading update from: ' + tar_download_url)
data = request.request_content(tar_download_url)
if not data:
logger.error("Unable to retrieve new version from '%s', can't update", tar_download_url)
return
download_name = plexpy.CONFIG.GIT_BRANCH + '-github'
tar_download_path = os.path.join(plexpy.PROG_DIR, download_name)
# Save tar to disk
with open(tar_download_path, 'wb') as f:
f.write(data)
# Extract the tar to update folder
logger.info('Extracting file: ' + tar_download_path)
tar = tarfile.open(tar_download_path)
tar.extractall(update_dir)
tar.close()
# Delete the tar.gz
logger.info('Deleting file: ' + tar_download_path)
os.remove(tar_download_path)
# Find update dir name
update_dir_contents = [x for x in os.listdir(update_dir) if os.path.isdir(os.path.join(update_dir, x))]
if len(update_dir_contents) != 1:
logger.error("Invalid update data, update failed: " + str(update_dir_contents))
return
content_dir = os.path.join(update_dir, update_dir_contents[0])
# walk temp folder and move files to main folder
for dirname, dirnames, filenames in os.walk(content_dir):
dirname = dirname[len(content_dir) + 1:]
for curfile in filenames:
old_path = os.path.join(content_dir, dirname, curfile)
new_path = os.path.join(plexpy.PROG_DIR, dirname, curfile)
if os.path.isfile(new_path):
os.remove(new_path)
os.renames(old_path, new_path)
# Update version.txt
try:
with open(version_path, 'w') as f:
f.write(str(plexpy.LATEST_VERSION))
except IOError as e:
logger.error(
"Unable to write current version to version.txt, update not complete: %s",
e
)
return

423
plexpy/webserve.py Normal file
View file

@ -0,0 +1,423 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
from plexpy import logger, db, helpers, notifiers
from plexpy.helpers import checked, radio, today, cleanName
from xml.dom import minidom
from mako.lookup import TemplateLookup
from mako import exceptions
from operator import itemgetter
import plexpy
import threading
import cherrypy
import urllib2
import hashlib
import random
import urllib
import json
import time
import sys
import os
try:
# pylint:disable=E0611
# ignore this error because we are catching the ImportError
from collections import OrderedDict
# pylint:enable=E0611
except ImportError:
# Python 2.6.x fallback, from libs
from ordereddict import OrderedDict
def serve_template(templatename, **kwargs):
interface_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/')
template_dir = os.path.join(str(interface_dir), plexpy.CONFIG.INTERFACE)
_hplookup = TemplateLookup(directories=[template_dir])
try:
template = _hplookup.get_template(templatename)
return template.render(**kwargs)
except:
return exceptions.html_error_template().render()
class WebInterface(object):
@cherrypy.expose
def index(self):
raise cherrypy.HTTPRedirect("home")
@cherrypy.expose
def home(self):
return serve_template(templatename="index.html", title="Home")
@cherrypy.expose
def history(self):
if plexpy.CONFIG.DATE_FORMAT:
date_format = plexpy.CONFIG.DATE_FORMAT
else:
date_format = 'YYYY-MM-DD'
if plexpy.CONFIG.TIME_FORMAT:
time_format = plexpy.CONFIG.TIME_FORMAT
else:
time_format = 'HH:mm'
return serve_template(templatename="history.html", title="History", date_format=date_format, time_format=time_format)
@cherrypy.expose
def checkGithub(self):
from plexpy import versioncheck
versioncheck.checkGithub()
raise cherrypy.HTTPRedirect("home")
@cherrypy.expose
def logs(self):
return serve_template(templatename="logs.html", title="Log", lineList=plexpy.LOG_LIST)
@cherrypy.expose
def clearLogs(self):
plexpy.LOG_LIST = []
logger.info("Web logs cleared")
raise cherrypy.HTTPRedirect("logs")
@cherrypy.expose
def toggleVerbose(self):
plexpy.VERBOSE = not plexpy.VERBOSE
logger.initLogger(console=not plexpy.QUIET,
log_dir=plexpy.CONFIG.LOG_DIR, verbose=plexpy.VERBOSE)
logger.info("Verbose toggled, set to %s", plexpy.VERBOSE)
logger.debug("If you read this message, debug logging is available")
raise cherrypy.HTTPRedirect("logs")
@cherrypy.expose
def getLog(self, iDisplayStart=0, iDisplayLength=100, iSortCol_0=0, sSortDir_0="desc", sSearch="", **kwargs):
iDisplayStart = int(iDisplayStart)
iDisplayLength = int(iDisplayLength)
filtered = []
if sSearch == "":
filtered = plexpy.LOG_LIST[::]
else:
filtered = [row for row in plexpy.LOG_LIST for column in row if sSearch.lower() in column.lower()]
sortcolumn = 0
if iSortCol_0 == '1':
sortcolumn = 2
elif iSortCol_0 == '2':
sortcolumn = 1
filtered.sort(key=lambda x: x[sortcolumn], reverse=sSortDir_0 == "desc")
rows = filtered[iDisplayStart:(iDisplayStart + iDisplayLength)]
rows = [[row[0], row[2], row[1]] for row in rows]
return json.dumps({
'iTotalDisplayRecords': len(filtered),
'iTotalRecords': len(plexpy.LOG_LIST),
'aaData': rows,
})
@cherrypy.expose
def generateAPI(self):
apikey = hashlib.sha224(str(random.getrandbits(256))).hexdigest()[0:32]
logger.info("New API generated")
return apikey
@cherrypy.expose
def config(self):
interface_dir = os.path.join(plexpy.PROG_DIR, 'data/interfaces/')
interface_list = [name for name in os.listdir(interface_dir) if os.path.isdir(os.path.join(interface_dir, name))]
config = {
"http_host": plexpy.CONFIG.HTTP_HOST,
"http_username": plexpy.CONFIG.HTTP_USERNAME,
"http_port": plexpy.CONFIG.HTTP_PORT,
"http_password": plexpy.CONFIG.HTTP_PASSWORD,
"launch_browser": checked(plexpy.CONFIG.LAUNCH_BROWSER),
"enable_https": checked(plexpy.CONFIG.ENABLE_HTTPS),
"https_cert": plexpy.CONFIG.HTTPS_CERT,
"https_key": plexpy.CONFIG.HTTPS_KEY,
"api_enabled": checked(plexpy.CONFIG.API_ENABLED),
"api_key": plexpy.CONFIG.API_KEY,
"update_db_interval": plexpy.CONFIG.UPDATE_DB_INTERVAL,
"freeze_db": checked(plexpy.CONFIG.FREEZE_DB),
"log_dir": plexpy.CONFIG.LOG_DIR,
"cache_dir": plexpy.CONFIG.CACHE_DIR,
"interface_list": interface_list,
"growl_enabled": checked(plexpy.CONFIG.GROWL_ENABLED),
"growl_host": plexpy.CONFIG.GROWL_HOST,
"growl_password": plexpy.CONFIG.GROWL_PASSWORD,
"prowl_enabled": checked(plexpy.CONFIG.PROWL_ENABLED),
"prowl_keys": plexpy.CONFIG.PROWL_KEYS,
"prowl_priority": plexpy.CONFIG.PROWL_PRIORITY,
"xbmc_enabled": checked(plexpy.CONFIG.XBMC_ENABLED),
"xbmc_host": plexpy.CONFIG.XBMC_HOST,
"xbmc_username": plexpy.CONFIG.XBMC_USERNAME,
"xbmc_password": plexpy.CONFIG.XBMC_PASSWORD,
"lms_enabled": checked(plexpy.CONFIG.LMS_ENABLED),
"lms_host": plexpy.CONFIG.LMS_HOST,
"plex_enabled": checked(plexpy.CONFIG.PLEX_ENABLED),
"plex_client_host": plexpy.CONFIG.PLEX_CLIENT_HOST,
"plex_username": plexpy.CONFIG.PLEX_USERNAME,
"plex_password": plexpy.CONFIG.PLEX_PASSWORD,
"nma_enabled": checked(plexpy.CONFIG.NMA_ENABLED),
"nma_apikey": plexpy.CONFIG.NMA_APIKEY,
"nma_priority": int(plexpy.CONFIG.NMA_PRIORITY),
"pushalot_enabled": checked(plexpy.CONFIG.PUSHALOT_ENABLED),
"pushalot_apikey": plexpy.CONFIG.PUSHALOT_APIKEY,
"synoindex_enabled": checked(plexpy.CONFIG.SYNOINDEX_ENABLED),
"pushover_enabled": checked(plexpy.CONFIG.PUSHOVER_ENABLED),
"pushover_keys": plexpy.CONFIG.PUSHOVER_KEYS,
"pushover_apitoken": plexpy.CONFIG.PUSHOVER_APITOKEN,
"pushover_priority": plexpy.CONFIG.PUSHOVER_PRIORITY,
"pushbullet_enabled": checked(plexpy.CONFIG.PUSHBULLET_ENABLED),
"pushbullet_apikey": plexpy.CONFIG.PUSHBULLET_APIKEY,
"pushbullet_deviceid": plexpy.CONFIG.PUSHBULLET_DEVICEID,
"subsonic_enabled": checked(plexpy.CONFIG.SUBSONIC_ENABLED),
"subsonic_host": plexpy.CONFIG.SUBSONIC_HOST,
"subsonic_username": plexpy.CONFIG.SUBSONIC_USERNAME,
"subsonic_password": plexpy.CONFIG.SUBSONIC_PASSWORD,
"twitter_enabled": checked(plexpy.CONFIG.TWITTER_ENABLED),
"osx_notify_enabled": checked(plexpy.CONFIG.OSX_NOTIFY_ENABLED),
"osx_notify_app": plexpy.CONFIG.OSX_NOTIFY_APP,
"boxcar_enabled": checked(plexpy.CONFIG.BOXCAR_ENABLED),
"boxcar_token": plexpy.CONFIG.BOXCAR_TOKEN,
"cache_sizemb": plexpy.CONFIG.CACHE_SIZEMB,
"mpc_enabled": checked(plexpy.CONFIG.MPC_ENABLED),
"email_enabled": checked(plexpy.CONFIG.EMAIL_ENABLED),
"email_from": plexpy.CONFIG.EMAIL_FROM,
"email_to": plexpy.CONFIG.EMAIL_TO,
"email_smtp_server": plexpy.CONFIG.EMAIL_SMTP_SERVER,
"email_smtp_user": plexpy.CONFIG.EMAIL_SMTP_USER,
"email_smtp_password": plexpy.CONFIG.EMAIL_SMTP_PASSWORD,
"email_smtp_port": int(plexpy.CONFIG.EMAIL_SMTP_PORT),
"email_tls": checked(plexpy.CONFIG.EMAIL_TLS),
"pms_ip": plexpy.CONFIG.PMS_IP,
"pms_port": plexpy.CONFIG.PMS_PORT,
"pms_username": plexpy.CONFIG.PMS_USERNAME,
"pms_password": plexpy.CONFIG.PMS_PASSWORD,
"plexwatch_database": plexpy.CONFIG.PLEXWATCH_DATABASE,
"date_format": plexpy.CONFIG.DATE_FORMAT,
"time_format": plexpy.CONFIG.TIME_FORMAT,
"grouping_global_history": checked(plexpy.CONFIG.GROUPING_GLOBAL_HISTORY),
"grouping_user_history": checked(plexpy.CONFIG.GROUPING_USER_HISTORY),
"grouping_charts": checked(plexpy.CONFIG.GROUPING_CHARTS)
}
return serve_template(templatename="config.html", title="Settings", config=config)
@cherrypy.expose
def configUpdate(self, **kwargs):
# Handle the variable config options. Note - keys with False values aren't getting passed
checked_configs = [
"launch_browser", "enable_https", "api_enabled", "freeze_db", "growl_enabled",
"prowl_enabled", "xbmc_enabled", "lms_enabled",
"plex_enabled", "nma_enabled", "pushalot_enabled",
"synoindex_enabled", "pushover_enabled", "pushbullet_enabled",
"subsonic_enabled", "twitter_enabled", "osx_notify_enabled",
"boxcar_enabled", "mpc_enabled", "email_enabled", "email_tls",
"grouping_global_history", "grouping_user_history", "grouping_charts"
]
for checked_config in checked_configs:
if checked_config not in kwargs:
# checked items should be zero or one. if they were not sent then the item was not checked
kwargs[checked_config] = 0
for plain_config, use_config in [(x[4:], x) for x in kwargs if x.startswith('use_')]:
# the use prefix is fairly nice in the html, but does not match the actual config
kwargs[plain_config] = kwargs[use_config]
del kwargs[use_config]
plexpy.CONFIG.process_kwargs(kwargs)
# Write the config
plexpy.CONFIG.write()
# Reconfigure scheduler
plexpy.initialize_scheduler()
raise cherrypy.HTTPRedirect("config")
@cherrypy.expose
def do_state_change(self, signal, title, timer):
plexpy.SIGNAL = signal
message = title + '...'
return serve_template(templatename="shutdown.html", title=title,
message=message, timer=timer)
@cherrypy.expose
def getHistory_json(self, iDisplayStart=0, iDisplayLength=100, sSearch="", iSortCol_0='0', sSortDir_0='asc', **kwargs):
iDisplayStart = int(iDisplayStart)
iDisplayLength = int(iDisplayLength)
filtered = []
totalcount = 0
myDB = db.DBConnection()
db_table = db.DBConnection().get_history_table_name()
sortcolumn = 'time'
sortbyhavepercent = False
if iSortCol_0 == '1':
sortcolumn = 'user'
if iSortCol_0 == '2':
sortcolumn = 'platform'
elif iSortCol_0 == '3':
sortcolumn = 'ip_address'
elif iSortCol_0 == '4':
sortcolumn = 'title'
elif iSortCol_0 == '5':
sortcolumn = 'time'
elif iSortCol_0 == '6':
sortcolumn = 'paused_counter'
elif iSortCol_0 == '7':
sortcolumn = 'stopped'
elif iSortCol_0 == '8':
sortbyhavepercent = True
if sSearch == "":
query = 'SELECT * from %s order by %s COLLATE NOCASE %s' % (db_table, sortcolumn, sSortDir_0)
filtered = myDB.select(query)
totalcount = len(filtered)
else:
query = 'SELECT * from ' + db_table + ' WHERE user LIKE "%' + sSearch + \
'%" OR title LIKE "%' + sSearch + '%"' + 'ORDER BY %s COLLATE NOCASE %s' % (sortcolumn, sSortDir_0)
filtered = myDB.select(query)
totalcount = myDB.select('SELECT COUNT(*) from processed')[0][0]
history = filtered[iDisplayStart:(iDisplayStart + iDisplayLength)]
rows = []
for item in history:
row = {"date": item['time'],
"user": item["user"],
"platform": item["platform"],
"ip_address": item["ip_address"],
"title": item["title"],
"started": item["time"],
"paused": item["paused_counter"],
"stopped": item["stopped"],
"duration": "",
"percent_complete": 0,
}
if item['paused_counter'] > 0:
row['paused'] = item['paused_counter']
else:
row['paused'] = 0
if item['time']:
if item['stopped'] > 0:
stopped = item['stopped']
else:
stopped = 0
if item['paused_counter'] > 0:
paused_counter = item['paused_counter']
else:
paused_counter = 0
row['duration'] = stopped - item['time'] + paused_counter
try:
xml_parse = minidom.parseString(helpers.latinToAscii(item['xml']))
except IOError, e:
logger.warn("Error parsing XML in PlexWatch db: %s" % e)
xml_head = xml_parse.getElementsByTagName('opt')
if not xml_head:
logger.warn("Error parsing XML in PlexWatch db: %s" % e)
for s in xml_head:
if s.getAttribute('duration') and s.getAttribute('viewOffset'):
view_offset = helpers.cast_to_float(s.getAttribute('viewOffset'))
duration = helpers.cast_to_float(s.getAttribute('duration'))
if duration > 0:
row['percent_complete'] = (view_offset / duration)*100
else:
row['percent_complete'] = 0
rows.append(row)
dict = {'iTotalDisplayRecords': len(filtered),
'iTotalRecords': totalcount,
'aaData': rows,
}
s = json.dumps(dict)
cherrypy.response.headers['Content-type'] = 'application/json'
return s
@cherrypy.expose
def shutdown(self):
return self.do_state_change('shutdown', 'Shutting Down', 15)
@cherrypy.expose
def restart(self):
return self.do_state_change('restart', 'Restarting', 30)
@cherrypy.expose
def update(self):
return self.do_state_change('update', 'Updating', 120)
@cherrypy.expose
def api(self, *args, **kwargs):
from plexpy.api import Api
a = Api()
a.checkParams(*args, **kwargs)
return a.fetchData()
@cherrypy.expose
def twitterStep1(self):
cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store"
tweet = notifiers.TwitterNotifier()
return tweet._get_authorization()
@cherrypy.expose
def twitterStep2(self, key):
cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store"
tweet = notifiers.TwitterNotifier()
result = tweet._get_credentials(key)
logger.info(u"result: " + str(result))
if result:
return "Key verification successful"
else:
return "Unable to verify key"
@cherrypy.expose
def testTwitter(self):
cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store"
tweet = notifiers.TwitterNotifier()
result = tweet.test_notify()
if result:
return "Tweet successful, check your twitter to make sure it worked"
else:
return "Error sending tweet"
@cherrypy.expose
def osxnotifyregister(self, app):
cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store"
from osxnotify import registerapp as osxnotify
result, msg = osxnotify.registerapp(app)
if result:
osx_notify = notifiers.OSX_NOTIFY()
osx_notify.notify('Registered', result, 'Success :-)')
logger.info('Registered %s, to re-register a different app, delete this app first' % result)
else:
logger.warn(msg)
return msg

124
plexpy/webstart.py Normal file
View file

@ -0,0 +1,124 @@
# This file is part of PlexPy.
#
# PlexPy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PlexPy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with PlexPy. If not, see <http://www.gnu.org/licenses/>.
import os
import sys
import cherrypy
import plexpy
from plexpy import logger
from plexpy.webserve import WebInterface
from plexpy.helpers import create_https_certificates
def initialize(options):
# HTTPS stuff stolen from sickbeard
enable_https = options['enable_https']
https_cert = options['https_cert']
https_key = options['https_key']
if enable_https:
# If either the HTTPS certificate or key do not exist, try to make
# self-signed ones.
if not (https_cert and os.path.exists(https_cert)) or not (https_key and os.path.exists(https_key)):
if not create_https_certificates(https_cert, https_key):
logger.warn("Unable to create certificate and key. Disabling " \
"HTTPS")
enable_https = False
if not (os.path.exists(https_cert) and os.path.exists(https_key)):
logger.warn("Disabled HTTPS because of missing certificate and " \
"key.")
enable_https = False
options_dict = {
'server.socket_port': options['http_port'],
'server.socket_host': options['http_host'],
'server.thread_pool': 10,
'tools.encode.on': True,
'tools.encode.encoding': 'utf-8',
'tools.decode.on': True,
'log.screen': False,
'engine.autoreload.on': False,
}
if enable_https:
options_dict['server.ssl_certificate'] = https_cert
options_dict['server.ssl_private_key'] = https_key
protocol = "https"
else:
protocol = "http"
logger.info("Starting PlexPy web server on %s://%s:%d/", protocol,
options['http_host'], options['http_port'])
cherrypy.config.update(options_dict)
conf = {
'/': {
'tools.staticdir.root': os.path.join(plexpy.PROG_DIR, 'data'),
'tools.proxy.on': options['http_proxy'] # pay attention to X-Forwarded-Proto header
},
'/interfaces': {
'tools.staticdir.on': True,
'tools.staticdir.dir': "interfaces"
},
'/images': {
'tools.staticdir.on': True,
'tools.staticdir.dir': "images"
},
'/css': {
'tools.staticdir.on': True,
'tools.staticdir.dir': "css"
},
'/js': {
'tools.staticdir.on': True,
'tools.staticdir.dir': "js"
},
'/favicon.ico': {
'tools.staticfile.on': True,
'tools.staticfile.filename': os.path.join(os.path.abspath(
os.curdir), "images" + os.sep + "favicon.ico")
},
'/cache': {
'tools.staticdir.on': True,
'tools.staticdir.dir': plexpy.CONFIG.CACHE_DIR
}
}
if options['http_password']:
logger.info("Web server authentication is enabled, username is '%s'", options['http_username'])
conf['/'].update({
'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'PlexPy web server',
'tools.auth_basic.checkpassword': cherrypy.lib.auth_basic.checkpassword_dict({
options['http_username']: options['http_password']
})
})
conf['/api'] = {'tools.auth_basic.on': False}
# Prevent time-outs
cherrypy.engine.timeout_monitor.unsubscribe()
cherrypy.tree.mount(WebInterface(), str(options['http_root']), config=conf)
try:
cherrypy.process.servers.check_port(str(options['http_host']), options['http_port'])
cherrypy.server.start()
except IOError:
sys.stderr.write('Failed to start on port: %i. Is something else running?\n' % (options['http_port']))
sys.exit(1)
cherrypy.server.wait()